Java Streams and Lambdas¶
Overview¶
This guide explores Java's functional programming capabilities through lambdas, streams, and functional interfaces. Introduced in Java 8, these features enable more concise, expressive code and facilitate parallel data processing. Streams provide a functional approach to processing collections of objects, while lambdas allow you to write inline, anonymous functions.
Prerequisites¶
- Solid understanding of Java core concepts
- Experience with Java collections
- Familiarity with object-oriented programming
- Basic understanding of method references
Learning Objectives¶
- Understand lambda expressions and their syntax
- Master functional interfaces in the java.util.function package
- Apply method references for more concise code
- Create and utilize Stream pipelines for data processing
- Transform data collections using map, filter, and reduce operations
- Apply terminal operations to produce results from streams
- Leverage parallel streams for improved performance
- Understand optional values and their proper usage
- Combine multiple functional operations for complex data transformations
- Recognize appropriate use cases for functional vs imperative approaches
Table of Contents¶
- Lambda Expressions
- Functional Interfaces
- Method References
- Introduction to Streams
- Intermediate Operations
- Terminal Operations
- Optional Class
- Parallel Streams
- Best Practices
- Common Pitfalls
Lambda Expressions¶
Lambda Syntax¶
Lambda expressions provide a concise way to express instances of single-method interfaces (functional interfaces).
Basic syntax: (parameters) -> expression
or (parameters) -> { statements; }
// Lambda with no parameters
Runnable runnable = () -> System.out.println("Hello, World!");
// Lambda with one parameter (type inferred)
Consumer<String> consumer = message -> System.out.println(message);
// Lambda with multiple parameters
Comparator<String> comparator = (s1, s2) -> s1.length() - s2.length();
// Lambda with explicit parameter types
BiFunction<Integer, Integer, Integer> add = (Integer a, Integer b) -> a + b;
// Multi-line lambda with block
Consumer<String> printer = message -> {
String formattedMessage = "Message: " + message;
System.out.println(formattedMessage);
};
Variable Capture¶
Lambdas can access variables from the surrounding scope:
String prefix = "User: ";
// Lambda capturing the prefix variable
Consumer<String> printUser = name -> System.out.println(prefix + name);
// Using the lambda
printUser.accept("John"); // Outputs: User: John
Variables used in lambda expressions must be effectively final (not changed after initialization).
int count = 0;
// Incorrect: trying to modify a captured variable
Runnable runnable = () -> {
count++; // Compile error: Variable used in lambda should be final or effectively final
};
// Correct: using AtomicInteger for mutable counter
AtomicInteger atomicCount = new AtomicInteger(0);
Runnable correctRunnable = () -> {
atomicCount.incrementAndGet(); // Works fine
};
Lambda vs Anonymous Classes¶
Lambdas are more concise than anonymous inner classes:
// Anonymous class approach
Runnable anonymousRunnable = new Runnable() {
@Override
public void run() {
System.out.println("Anonymous class");
}
};
// Lambda approach
Runnable lambdaRunnable = () -> System.out.println("Lambda expression");
Key differences:
- this
refers to the enclosing instance in a lambda, but to the anonymous class instance in an anonymous class
- Lambdas don't introduce a new scope for variables
- Lambdas are more memory-efficient
Functional Interfaces¶
Core Functional Interfaces¶
Java 8 introduced several predefined functional interfaces in the java.util.function
package:
Function¶
Represents a function that takes one argument and produces a result.
Function<String, Integer> stringLength = s -> s.length();
Integer length = stringLength.apply("Hello"); // returns 5
// Function composition
Function<Integer, Integer> multiply = n -> n * 2;
Function<Integer, Integer> add = n -> n + 3;
Function<Integer, Integer> multiplyThenAdd = multiply.andThen(add);
Integer result1 = multiplyThenAdd.apply(5); // (5 * 2) + 3 = 13
Function<Integer, Integer> addThenMultiply = multiply.compose(add);
Integer result2 = addThenMultiply.apply(5); // (5 + 3) * 2 = 16
Predicate¶
Represents a function that takes one argument and returns a boolean.
Predicate<String> isEmpty = s -> s.isEmpty();
boolean result = isEmpty.test(""); // returns true
// Predicate composition
Predicate<String> isNotEmpty = isEmpty.negate();
Predicate<String> isLong = s -> s.length() > 10;
Predicate<String> isLongAndNotEmpty = isNotEmpty.and(isLong);
boolean test1 = isLongAndNotEmpty.test("Hello World!"); // true
boolean test2 = isLongAndNotEmpty.test(""); // false
boolean test3 = isLongAndNotEmpty.test("Hello"); // false
Predicate<String> isShortOrEmpty = isLong.negate().or(isEmpty);
Consumer¶
Represents an operation that takes one argument and returns no result.
Consumer<String> print = s -> System.out.println(s);
print.accept("Hello"); // prints "Hello"
// Consumer chaining
Consumer<String> log = s -> System.out.println("Log: " + s);
Consumer<String> printThenLog = print.andThen(log);
printThenLog.accept("Hello");
// Prints:
// Hello
// Log: Hello
Supplier¶
Represents a supplier of results, takes no input but returns a value.
Supplier<Double> randomValue = () -> Math.random();
Double value = randomValue.get(); // returns a random double
Supplier<List<String>> listSupplier = () -> new ArrayList<>();
List<String> newList = listSupplier.get(); // returns a new empty ArrayList
BiFunction¶
Takes two arguments and produces a result.
BiFunction<String, String, String> concat = (a, b) -> a + b;
String result = concat.apply("Hello, ", "World!"); // "Hello, World!"
BiFunction<Integer, Integer, Integer> multiply = (a, b) -> a * b;
Integer product = multiply.apply(5, 7); // 35
Specialized Functional Interfaces¶
Java provides specialized interfaces for primitive types to avoid boxing/unboxing:
// For int operations
IntPredicate isEven = n -> n % 2 == 0;
boolean check = isEven.test(4); // true
IntFunction<String> intToString = n -> String.valueOf(n);
String str = intToString.apply(123); // "123"
IntConsumer printInt = n -> System.out.println(n);
printInt.accept(42); // prints 42
IntSupplier randomInt = () -> new Random().nextInt(100);
int value = randomInt.getAsInt(); // random int between 0-99
IntUnaryOperator square = n -> n * n;
int squared = square.applyAsInt(5); // 25
IntBinaryOperator sum = (a, b) -> a + b;
int total = sum.applyAsInt(10, 20); // 30
// Similar interfaces exist for long and double:
// LongPredicate, LongFunction, LongConsumer, etc.
// DoublePredicate, DoubleFunction, DoubleConsumer, etc.
Creating Custom Functional Interfaces¶
You can define your own functional interfaces:
@FunctionalInterface
public interface TriFunction<T, U, V, R> {
R apply(T t, U u, V v);
}
// Using custom functional interface
TriFunction<Integer, Integer, Integer, Integer> sum3 =
(a, b, c) -> a + b + c;
Integer result = sum3.apply(1, 2, 3); // 6
The @FunctionalInterface
annotation enforces that the interface has exactly one abstract method.
Method References¶
Method references provide a shorthand notation for lambdas that simply call an existing method.
Types of Method References¶
1. Reference to a static method: ClassName::staticMethod
¶
// Instead of: Function<String, Integer> parser = s -> Integer.parseInt(s);
Function<String, Integer> parser = Integer::parseInt;
Integer value = parser.apply("123"); // 123
2. Reference to an instance method of a particular object: instance::instanceMethod
¶
String greeting = "Hello";
// Instead of: Supplier<String> supplier = () -> greeting.toUpperCase();
Supplier<String> supplier = greeting::toUpperCase;
String result = supplier.get(); // "HELLO"
3. Reference to an instance method of an arbitrary object of a particular type: ClassName::instanceMethod
¶
// Instead of: Function<String, Integer> lengthFunc = s -> s.length();
Function<String, Integer> lengthFunc = String::length;
Integer length = lengthFunc.apply("Hello"); // 5
// Instead of: BiPredicate<String, String> contains = (s, substr) -> s.contains(substr);
BiPredicate<String, String> contains = String::contains;
boolean result = contains.test("Hello World", "World"); // true
4. Reference to a constructor: ClassName::new
¶
// Instead of: Supplier<List<String>> listSupplier = () -> new ArrayList<>();
Supplier<List<String>> listSupplier = ArrayList::new;
List<String> list = listSupplier.get(); // new ArrayList
// With a parameter
// Instead of: Function<Integer, List<String>> sizedListCreator = n -> new ArrayList<>(n);
Function<Integer, List<String>> sizedListCreator = ArrayList::new;
List<String> sizedList = sizedListCreator.apply(10); // ArrayList with initial capacity 10
Introduction to Streams¶
What is a Stream?¶
A stream is a sequence of elements that supports sequential and parallel aggregate operations. It is not a data structure but a view of the data from a source like collections, arrays, or I/O channels.
// Creating streams from different sources
List<String> names = Arrays.asList("John", "Jane", "Adam", "Eve");
// From a collection
Stream<String> streamFromCollection = names.stream();
// From an array
String[] namesArray = {"John", "Jane", "Adam", "Eve"};
Stream<String> streamFromArray = Arrays.stream(namesArray);
// From individual values
Stream<String> streamOfValues = Stream.of("John", "Jane", "Adam", "Eve");
// Empty stream
Stream<String> emptyStream = Stream.empty();
// Infinite streams
Stream<Integer> infiniteIntegers = Stream.iterate(0, n -> n + 1);
Stream<Double> infiniteRandoms = Stream.generate(Math::random);
Stream Pipeline¶
A stream pipeline consists of: 1. A source 2. Zero or more intermediate operations 3. A terminal operation
List<String> names = Arrays.asList("John", "Jane", "Adam", "Eve");
long count = names.stream() // Source
.filter(s -> s.startsWith("J")) // Intermediate operation
.map(String::toUpperCase) // Intermediate operation
.count(); // Terminal operation
System.out.println("Count: " + count); // 2
Stream Characteristics¶
- Laziness: Intermediate operations are lazy and only executed when a terminal operation is invoked
- Once-use: A stream can only be used once
- Non-interference: Data sources should not be modified during stream operations
- Stateless behavior: For best performance, operations should not depend on any state outside the operation
Stream<String> stream = names.stream();
stream.forEach(System.out::println);
// stream.count(); // Throws IllegalStateException: stream has already been operated upon or closed
Intermediate Operations¶
Intermediate operations transform a stream into another stream and are lazy (not executed until a terminal operation is invoked).
Filtering Operations¶
filter¶
Filters elements based on a predicate.
List<String> names = Arrays.asList("John", "Jane", "Adam", "Eve", "Alice");
List<String> filteredNames = names.stream()
.filter(name -> name.length() > 3)
.collect(Collectors.toList());
// filteredNames: [John, Jane, Adam, Alice]
distinct¶
Returns a stream with distinct elements.
List<Integer> numbers = Arrays.asList(1, 2, 2, 3, 3, 3, 4, 5, 5);
List<Integer> distinctNumbers = numbers.stream()
.distinct()
.collect(Collectors.toList());
// distinctNumbers: [1, 2, 3, 4, 5]
limit¶
Limits the stream to a specified size.
List<Integer> first3Numbers = numbers.stream()
.limit(3)
.collect(Collectors.toList());
// first3Numbers: [1, 2, 2]
skip¶
Skips the first n elements.
List<Integer> skipFirst2 = numbers.stream()
.skip(2)
.collect(Collectors.toList());
// skipFirst2: [2, 3, 3, 3, 4, 5, 5]
Transformation Operations¶
map¶
Transforms each element using a function.
List<String> names = Arrays.asList("John", "Jane", "Adam");
List<Integer> nameLengths = names.stream()
.map(String::length)
.collect(Collectors.toList());
// nameLengths: [4, 4, 4]
List<String> upperCaseNames = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
// upperCaseNames: [JOHN, JANE, ADAM]
mapToInt, mapToLong, mapToDouble¶
Maps to primitive streams.
List<String> names = Arrays.asList("John", "Jane", "Adam");
// Using mapToInt to avoid boxing
int totalLength = names.stream()
.mapToInt(String::length)
.sum();
// totalLength: 12
flatMap¶
Flattens nested streams into a single stream.
List<List<Integer>> nestedLists = Arrays.asList(
Arrays.asList(1, 2, 3),
Arrays.asList(4, 5, 6),
Arrays.asList(7, 8, 9)
);
List<Integer> flattenedList = nestedLists.stream()
.flatMap(Collection::stream)
.collect(Collectors.toList());
// flattenedList: [1, 2, 3, 4, 5, 6, 7, 8, 9]
// Another example with strings
List<String> words = Arrays.asList("Hello", "World");
List<String> letters = words.stream()
.flatMap(word -> Arrays.stream(word.split("")))
.collect(Collectors.toList());
// letters: [H, e, l, l, o, W, o, r, l, d]
Ordering Operations¶
sorted¶
Sorts the elements of the stream.
List<String> names = Arrays.asList("John", "Jane", "Adam", "Eve");
// Natural order
List<String> sortedNames = names.stream()
.sorted()
.collect(Collectors.toList());
// sortedNames: [Adam, Eve, Jane, John]
// Custom order
List<String> sortedByLength = names.stream()
.sorted(Comparator.comparing(String::length).thenComparing(Comparator.naturalOrder()))
.collect(Collectors.toList());
// sortedByLength: [Eve, Adam, Jane, John]
peek¶
Allows operations to be performed on elements as they flow through a stream, mainly for debugging.
List<String> result = names.stream()
.filter(name -> name.length() > 3)
.peek(name -> System.out.println("Filtered: " + name))
.map(String::toUpperCase)
.peek(name -> System.out.println("Mapped: " + name))
.collect(Collectors.toList());
// Output:
// Filtered: John
// Mapped: JOHN
// Filtered: Jane
// Mapped: JANE
// Filtered: Adam
// Mapped: ADAM
Terminal Operations¶
Terminal operations produce a result or side effect and cause the Stream pipeline to be executed.
Reduction Operations¶
reduce¶
Combines elements into a single result.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
// Sum using reduce
int sum = numbers.stream()
.reduce(0, (a, b) -> a + b);
// or
int sum2 = numbers.stream()
.reduce(0, Integer::sum);
// sum and sum2: 15
// Finding maximum
Optional<Integer> max = numbers.stream()
.reduce(Integer::max);
// max.get(): 5
// Concatenating strings
String concatenated = Stream.of("a", "b", "c")
.reduce("", (a, b) -> a + b);
// concatenated: "abc"
// More complex reduction
int sumOfSquares = numbers.stream()
.reduce(0, (sum, num) -> sum + num * num, Integer::sum);
// sumOfSquares: 55 (1 + 4 + 9 + 16 + 25)
Collection Operations¶
collect¶
Gathers elements into a collection or other data structure.
List<String> names = Arrays.asList("John", "Jane", "Adam", "Eve");
// Collecting to different collections
List<String> namesList = names.stream()
.collect(Collectors.toList());
Set<String> namesSet = names.stream()
.collect(Collectors.toSet());
// Joining elements
String joined = names.stream()
.collect(Collectors.joining(", "));
// joined: "John, Jane, Adam, Eve"
// Grouping
Map<Integer, List<String>> groupedByLength = names.stream()
.collect(Collectors.groupingBy(String::length));
// groupedByLength: {3=[Eve], 4=[John, Jane, Adam]}
// Partitioning
Map<Boolean, List<String>> partitioned = names.stream()
.collect(Collectors.partitioningBy(name -> name.startsWith("J")));
// partitioned: {false=[Adam, Eve], true=[John, Jane]}
// Statistics for numeric streams
IntSummaryStatistics stats = names.stream()
.collect(Collectors.summarizingInt(String::length));
// stats.getAverage(), stats.getSum(), stats.getMin(), stats.getMax(), stats.getCount()
// Custom collector for calculating average length
Double averageLength = names.stream()
.collect(Collectors.averagingInt(String::length));
// averageLength: 3.75
Iteration and Search Operations¶
forEach¶
Performs an action on each element.
names.stream()
.forEach(System.out::println);
// For parallel streams, forEachOrdered preserves the encounter order
names.parallelStream()
.forEachOrdered(System.out::println);
findFirst, findAny¶
Find the first element or any element that matches.
Optional<String> first = names.stream()
.findFirst();
// first.get(): "John"
Optional<String> any = names.stream()
.findAny();
// any.get(): could be any element, but typically "John" for sequential streams
// Finding first element that matches a condition
Optional<String> firstWithJ = names.stream()
.filter(name -> name.startsWith("J"))
.findFirst();
// firstWithJ.get(): "John"
anyMatch, allMatch, noneMatch¶
Check if any, all, or no elements match a predicate.
boolean hasJ = names.stream()
.anyMatch(name -> name.startsWith("J"));
// hasJ: true
boolean allShort = names.stream()
.allMatch(name -> name.length() < 5);
// allShort: true
boolean noZ = names.stream()
.noneMatch(name -> name.startsWith("Z"));
// noZ: true
count¶
Counts the elements in the stream.
long count = names.stream()
.filter(name -> name.contains("a"))
.count();
// count: 2 (Jane, Adam)
min, max¶
Find the minimum or maximum element according to a comparator.
Optional<String> shortest = names.stream()
.min(Comparator.comparing(String::length));
// shortest.get(): "Eve"
Optional<String> alphabeticallyFirst = names.stream()
.min(String::compareTo);
// alphabeticallyFirst.get(): "Adam"
toArray¶
Converts the stream to an array.
String[] namesArray = names.stream()
.toArray(String[]::new);
Optional Class¶
Optional<T>
is a container object that may or may not contain a non-null value, helping to avoid NullPointerException
.
Creating Optionals¶
// Empty Optional
Optional<String> empty = Optional.empty();
// From a non-null value
Optional<String> withValue = Optional.of("Hello");
// From a potentially null value
String nullableValue = getValueThatMightBeNull();
Optional<String> withNullable = Optional.ofNullable(nullableValue);
Checking for Values¶
Optional<String> optional = Optional.ofNullable(getValue());
// Check if a value is present
if (optional.isPresent()) {
System.out.println("Value: " + optional.get());
}
// Check if empty
if (optional.isEmpty()) { // Java 11+
System.out.println("No value present");
}
Working with Optional Values¶
Optional<String> optional = Optional.ofNullable(getValue());
// Execute if present
optional.ifPresent(value -> System.out.println("Value: " + value));
// Get if present, otherwise return default
String result = optional.orElse("Default");
// Get if present, otherwise compute default
String computed = optional.orElseGet(() -> computeDefault());
// Get if present, otherwise throw exception
String value = optional.orElseThrow(() -> new NoSuchElementException("No value"));
// Transform if present
Optional<Integer> length = optional.map(String::length);
// Filter values
Optional<String> filtered = optional.filter(s -> s.length() > 5);
// FlatMap for when the mapping function returns an Optional
Optional<String> upperCase = optional.flatMap(this::toUpperCaseOptional);
Example: Stream with Optional Results¶
List<Optional<String>> optionals = Arrays.asList(
Optional.of("Hello"),
Optional.empty(),
Optional.of("World")
);
// Filter out empty Optionals and get the values
List<String> filteredValues = optionals.stream()
.filter(Optional::isPresent)
.map(Optional::get)
.collect(Collectors.toList());
// filteredValues: [Hello, World]
// Using flatMap with Optional (Java 9+)
List<String> flatMappedValues = optionals.stream()
.flatMap(Optional::stream)
.collect(Collectors.toList());
// flatMappedValues: [Hello, World]
Parallel Streams¶
Parallel streams distribute work across multiple threads for potential performance improvements.
Creating Parallel Streams¶
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// Method 1: From a sequential stream
Stream<Integer> parallelStream1 = numbers.stream().parallel();
// Method 2: Directly from a collection
Stream<Integer> parallelStream2 = numbers.parallelStream();
Performance Considerations¶
// Example: Sum calculation
long start = System.currentTimeMillis();
// Sequential sum
long sequentialSum = LongStream.rangeClosed(1, 100_000_000)
.sum();
long sequentialTime = System.currentTimeMillis() - start;
System.out.println("Sequential sum: " + sequentialTime + " ms");
// Parallel sum
start = System.currentTimeMillis();
long parallelSum = LongStream.rangeClosed(1, 100_000_000)
.parallel()
.sum();
long parallelTime = System.currentTimeMillis() - start;
System.out.println("Parallel sum: " + parallelTime + " ms");
Maintaining Order¶
// Order is preserved by default, even in parallel streams
List<String> orderedResult = names.parallelStream()
.sorted()
.collect(Collectors.toList());
// For operations where order doesn't matter
Set<String> unorderedResult = names.parallelStream()
.collect(Collectors.toSet());
When to Use Parallel Streams¶
Good candidates for parallel streams: - Large datasets - Computationally intensive operations - Easily divisible problems - No shared mutable state
Poor candidates: - Small datasets - Operations with dependencies - I/O-bound operations - Operations requiring order
// Good: Computationally intensive with large dataset
List<BigInteger> results = LongStream.rangeClosed(1, 10_000)
.parallel()
.mapToObj(BigInteger::valueOf)
.map(n -> n.pow(100000))
.collect(Collectors.toList());
// Bad: Small dataset with simple operation
List<String> upperCaseNames = names.parallelStream() // Overhead likely exceeds benefit
.map(String::toUpperCase)
.collect(Collectors.toList());
Best Practices¶
-
Prefer method references over lambdas when possible
// Good list.stream().map(String::toUpperCase); // Less concise list.stream().map(s -> s.toUpperCase());
-
Use specialized stream types for primitives
// Better performance IntStream.range(1, 100).sum(); // Boxing/unboxing overhead Stream.iterate(1, n -> n + 1).limit(99).mapToInt(Integer::intValue).sum();
-
Consider the right collector for the job
// Collecting to the right data structure matters Map<Boolean, List<Person>> peopleByGender = people.stream() .collect(Collectors.partitioningBy(Person::isMale));
-
Avoid side effects in stream operations
// Bad: Side effects in lambda List<String> results = new ArrayList<>(); stream.forEach(item -> results.add(item.toUpperCase())); // Mutation! // Good: No side effects List<String> results = stream.map(String::toUpperCase) .collect(Collectors.toList());
-
Choose streams for functional operations, loops for imperative code
// Good use of streams for functional transformation List<String> upperCaseNames = names.stream() .map(String::toUpperCase) .collect(Collectors.toList()); // Better as a for loop if doing imperative operations for (String name : names) { System.out.println("Processing: " + name); // Complex imperative logic... }
-
Use parallel streams judiciously
// Only parallelize when it makes sense Stream<String> parallelStream = hugeList.parallelStream();
-
Limit the use of distinct() on large streams
// Can be memory-intensive on large streams Stream<String> uniqueItems = hugeStream.distinct();
-
Break down complex operations
// Hard to read and debug result = people.stream() .filter(p -> p.getAge() > 20) .map(Person::getName) .filter(name -> name.startsWith("A")) .map(String::toUpperCase) .sorted() .collect(Collectors.joining(", ")); // More readable when broken down Stream<Person> adultsStream = people.stream() .filter(p -> p.getAge() > 20); Stream<String> namesStream = adultsStream.map(Person::getName); Stream<String> filteredNamesStream = namesStream.filter(name -> name.startsWith("A")); Stream<String> processedNamesStream = filteredNamesStream.map(String::toUpperCase) .sorted(); result = processedNamesStream.collect(Collectors.joining(", "));
-
Use Optional properly
// Don't do this Optional<String> result = getOptionalString(); if (result.isPresent()) { return result.get(); } else { return "default"; } // Do this instead return getOptionalString().orElse("default");
-
Understand lazy evaluation
// This doesn't print anything yet (lazy) Stream<String> stream = names.stream() .filter(name -> { System.out.println("Filtering: " + name); return name.startsWith("J"); }) .map(name -> { System.out.println("Mapping: " + name); return name.toUpperCase(); }); // This triggers evaluation List<String> result = stream.collect(Collectors.toList());
Common Pitfalls¶
1. Reusing Streams¶
Problem: A stream can only be consumed once.
Stream<String> stream = names.stream();
stream.forEach(System.out::println);
// This will fail with IllegalStateException
stream.filter(name -> name.startsWith("J")).forEach(System.out::println);
Solution: Create a new stream when needed.
names.stream().forEach(System.out::println);
names.stream().filter(name -> name.startsWith("J")).forEach(System.out::println);
2. Side Effects in Lambdas¶
Problem: Modifying variables from lambda expressions can cause unexpected behavior.
// Avoid this!
List<String> filteredList = new ArrayList<>();
names.stream().filter(name -> {
if (name.startsWith("J")) {
filteredList.add(name); // Side effect!
return false;
}
return true;
}).forEach(System.out::println);
Solution: Use proper stream methods.
List<String> startsWithJ = names.stream()
.filter(name -> name.startsWith("J"))
.collect(Collectors.toList());
List<String> doesNotStartWithJ = names.stream()
.filter(name -> !name.startsWith("J"))
.collect(Collectors.toList());
3. Ignoring Return Values of Stream Operations¶
Problem: Stream operations return new streams that must be captured.
// This doesn't modify the original stream
names.stream().filter(name -> name.startsWith("J"));
// No terminal operation, nothing happens
Solution: Capture the result and use a terminal operation.
List<String> filtered = names.stream()
.filter(name -> name.startsWith("J"))
.collect(Collectors.toList());
4. Misunderstanding Parallel Stream Behavior¶
Problem: Parallel streams don't always improve performance and can cause issues with stateful operations.
// May not be faster, adds thread coordination overhead
List<String> result = smallList.parallelStream()
.map(String::toUpperCase)
.collect(Collectors.toList());
// Problematic: stateful operation with shared mutable state
List<String> collected = new ArrayList<>();
names.parallelStream().forEach(collected::add); // Order not guaranteed, potential thread safety issues
Solution: Use parallel streams only when appropriate and ensure thread safety.
// For stateful operations, use a concurrent collection or proper collector
List<String> collected = names.parallelStream()
.collect(Collectors.toList()); // Thread-safe collection operation
5. Incorrect Use of Optional¶
Problem: Using Optional incorrectly negates its benefits.
// Don't do this
Optional<String> opt = findName();
if (opt.isPresent()) {
String name = opt.get();
// Use name
} else {
// Handle empty case
}
// Even worse
String name = findName().get(); // May throw NoSuchElementException
Solution: Use Optional's methods.
findName().ifPresent(name -> {
// Use name
});
String name = findName().orElse("Default");
findName().ifPresentOrElse(
name -> { /* Use name */ },
() -> { /* Handle empty case */ }
);
6. Memory Issues with Large Streams¶
Problem: Some operations can consume large amounts of memory.
// This loads all elements into memory for sorting
List<String> sorted = hugeStream.sorted().collect(Collectors.toList());
// This keeps all elements in memory for distinct operation
List<String> unique = hugeStream.distinct().collect(Collectors.toList());
Solution: Process data in chunks or use appropriate data structures.
// Process in batches
AtomicInteger counter = new AtomicInteger();
List<List<String>> batches = hugeStream
.collect(Collectors.groupingBy(item -> counter.getAndIncrement() / BATCH_SIZE))
.values()
.stream()
.collect(Collectors.toList());
// For distinct elements, consider using a Set if appropriate
Set<String> uniqueItems = hugeStream.collect(Collectors.toSet());
Resources for Further Learning¶
- Official Documentation:
- Java Stream API Documentation
- Java Lambda Expressions
-
Books:
- "Java 8 in Action" by Raoul-Gabriel Urma, Mario Fusco, and Alan Mycroft
- "Modern Java in Action" (updated version of the above for Java 9+)
- "Effective Java" by Joshua Bloch (chapter on lambdas and streams)
-
"Functional Programming in Java" by Venkat Subramaniam
-
Online Resources:
- Baeldung's Java 8 Streams Tutorial
- Oracle's Lambda Expressions Tutorial
-
Practice Platforms:
- Coding Exercises on Java 8 Streams
- Java 8 Stream API Exercises on GitHub
Practice Exercises¶
-
Basic Stream Operations: Transform a list of strings to uppercase, filter those starting with 'A', and collect to a list.
-
Grouping and Partitioning: Given a list of people with age and gender, group them by age decades and gender.
-
Numeric Streams: Calculate statistics (min, max, average, sum) for a list of product prices.
-
Collectors: Use various collectors to transform a list of transactions into meaningful summaries.
-
Parallel Stream Performance: Compare performance of sequential and parallel streams for CPU-intensive operations.
-
Composing Lambdas: Create composite functions by combining multiple lambdas.
-
Optional Handling: Implement a chain of optional operations to safely navigate a complex object graph.
-
Custom Collectors: Create a custom collector to perform a specific aggregation operation not covered by standard collectors.
-
Stream Generator: Generate Fibonacci sequence using Stream.iterate.
-
Real-world Application: Process a large dataset (like CSV file) using streams for filtering, transformation, and aggregation.