8.7 Stream API and Collection Pipelines
Prerequisites: Lambdas and method calls; List is used here before the collection details in Chapter 10 Estimated time: 30 minutes Runtime environment: Java 17 You are ready to continue when: You can build and explain a stream pipeline without hidden side effects
The List Shouldn't Be Swallowed by Temporary Variables
Suppose we need the first five adult participants after formatting and sorting their names. For now, read List<Participant> as an ordered list of participants; the complete example below defines Participant. The Stream API expresses the query as a pipeline: data flows in from a source, passes through intermediate operations, and finally emerges through a terminal operation.
List<String> names = participants.stream()
.filter(participant -> participant.age() >= 18)
.map(participant -> "[" + participant.team() + "] " + participant.name())
.sorted()
.limit(5)
.toList();A Stream is not a new name for a collection. A collection stores data; a stream provides one processing pass rather than a reusable container. Stateful operations such as sorting may still buffer elements during that pass.
1. A Pipeline Has Three Parts
source intermediate operations terminal operations
participants.stream() -> filter -> map -> sorted -> limit -> toList| Part | Purpose | Common Examples |
|---|---|---|
| Source | Provides elements | list.stream(), Stream.of(...), Files.lines(...) |
| Intermediate Operations | Returns a new Stream, composing processing steps | filter, map, distinct, sorted, limit |
| Terminal Operations | Triggers traversal and produces a result or side effect | toList, collect, count, reduce, forEach |
Intermediate operations are lazy. When you call filter and map, you're just assembling the pipeline, no traversal occurs until a terminal operation is invoked:
Stream<String> pipeline = List.of("Iron", "Wood", "Grain")
.stream()
.map(item -> {
System.out.println("Processing:" + item);
return item + "Box";
});
System.out.println("The pipeline has been set up");
long count = pipeline.count();
System.out.println("Boxes:" + count);Here the List has a known size, and map cannot change the element count. The count operation may therefore obtain the size directly and skip map entirely; only the setup message and Boxes:3 may appear. Do not rely on intermediate print calls to implement business logic. Stream implementations may short-circuit, and some terminal operations might not execute all the steps you expect.
2. Method References Still Need a Target Type
When a Lambda simply passes parameters to an existing method, a method reference may be considered:
Consumer<String> printer = System.out::println; // Instance method of a specific object
Function<String, Integer> parser = Integer::parseInt; // Static method
Function<String, Integer> length = String::length; // Instance method on the supplied String
Supplier<List<String>> factory = ArrayList::new; // Constructor methodThe four forms are object::instance method, class::static method, class::instance method, and class::new. These still rely on the target type for overload resolution; method references do not always provide clearer intent than Lambdas. When parameters need to be adjusted, supplemented, or validated, writing a Lambda directly often remains more intuitive.
3. Common Intermediate Operations
List<Integer> numbers = List.of(3, 1, 4, 1, 5, 9, 2, 6);
List<Integer> result = numbers.stream()
.filter(number -> number % 2 != 0) // 3, 1, 1, 5, 9
.distinct() // 3, 1, 5, 9
.sorted() // 1, 3, 5, 9
.map(number -> number * 10) // 10, 30, 50, 90
.toList();filterretains elements that satisfy a predicate;maptransforms each element into another value;flatMapflattens multiple values produced by each element into a single stream;distinctremoves duplicates based onequals/hashCode;sortedrequires a natural ordering or an explicitComparator;limitandskipslice the sequence;takeWhileanddropWhileprocess contiguous prefixes of ordered streams.
sorted may require caching all elements, while filter can typically process items one at a time. In large-scale data pipelines, it's often more resource-efficient to apply filtering operations early (those that visibly reduce data volume) provided the semantic correctness of the pipeline is preserved.
4. Terminal Operations and Empty Results
long adultCount = participants.stream()
.filter(participant -> participant.age() >= 18)
.count();
boolean hasSenior = participants.stream()
.anyMatch(participant -> participant.age() >= 65);
long totalAge = participants.stream()
.mapToLong(Participant::age)
.sum();
double averageAge = participants.stream()
.mapToInt(Participant::age)
.average()
.orElse(0.0);Operations such as findFirst, reduce without an initial value, and average may produce no result and thus return Optional, OptionalInt, or OptionalDouble. The caller must clearly understand the business meaning of empty inputs and cannot unconditionally get().
The age total above widens to long before summing to avoid overflowing int. Long still has a limit; streams do not automatically switch to arbitrary-precision arithmetic. When processing numbers, prioritize the primitive streams generated by mapToInt, mapToLong, and mapToDouble. These provide sum, average, and summaryStatistics, and avoid unnecessary boxing.
5. Understand the Contract When Collecting Results
Since Java 16, Stream.toList() returns an unmodifiable list. It does not deep-copy elements or make mutable elements immutable:
List<String> names = participants.stream()
.map(Participant::name)
.toList();
// names.add("New participant"); // UnsupportedOperationExceptionIf a mutable result is explicitly required, you can write:
List<String> mutableNames = participants.stream()
.map(Participant::name)
.collect(Collectors.toCollection(ArrayList::new));Do not rely on the specific implementation or mutability of Collectors.toList(); its contract does not guarantee the list type or mutability.
When collecting into a Map, you must also handle duplicate keys:
Map<String, Integer> oldestByTeam = participants.stream()
.collect(Collectors.toMap(
Participant::team,
Participant::age,
Integer::max));Without the merge function, duplicate team names cause IllegalStateException. The merge policy must follow business meaning rather than keeping an arbitrary value just to suppress the exception.
6. A Stream Can Be Consumed Only Once
Stream<String> stream = List.of("Status", "Weather").stream();
long count = stream.count();
// stream.findFirst(); // IllegalStateExceptionAfter a terminal operation, the stream is consumed and cannot be reused. This does not automatically close any I/O resources it holds. An implementation throws IllegalStateException when it detects reuse, but the API does not guarantee detection of every invalid reuse. To iterate over it again, you must re-call stream() on the collection. If the source itself is readable only once, decide whether it should be materialized into a collection.
A stream derived from I/O may still hold resources. For example, use try-with-resources with Files.lines(path) to close the stream:
try (Stream<String> lines = Files.lines(path)) {
long nonBlank = lines.filter(line -> !line.isBlank()).count();
System.out.println(nonBlank);
}7. Behavioral Parameters Should Minimize Side Effects
Parameters passed to operations like filter and map must satisfy two key constraints:
- Do not alter the source: During iteration, never modify the collection being used as the source;
- Be stateless whenever possible: Ensure the result does not depend on external, mutable state.
The following approach inserts business logging directly into peek, which may seem clear at first glance, but is actually unreliable:
orders.stream()
.peek(order -> auditService.record(order)) // Do not assume required business actions
.map(Order::total)
.findFirst();Short-circuiting operations may process only a subset of elements, and certain optimizations might skip stages that don’t affect the final outcome. peek is suitable for temporary observation or debugging, but it is not appropriate for mandatory operations such as auditing, billing, or writing to a database. Any necessary side effects should be handled in well-defined imperative code or in dedicated terminal processing stages.
8. Parallel Streams Are Not "Free Acceleration"
In common JDK implementations, a parallel stream started from an ordinary calling thread usually uses the common ForkJoinPool. The Stream API does not promise a fixed executor, and the execution context can affect which pool is used. Whether performance improves depends on data volume, splitting overhead, computational cost per element, merging cost, available cores, and the impact of other tasks running in the same process.
int total = numbers.parallelStream()
.mapToInt(Integer::intValue)
.sum();Reduction operations must satisfy associativity and be safe for concurrent execution; shared mutable counters introduce race conditions. On a parallel stream, forEach does not guarantee encounter order. Use forEachOrdered when that order is required. However, ordering constraints may negate the benefits of parallelism.
During early learning, default to sequential streams. In real-world projects, if parallel streams are considered, first verify semantics, then measure performance with representative workloads, and understand how the common thread pool interacts with the rest of the application.
9. Runnable Participant Report
Save the following content as StreamDemo.java:
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
public class StreamDemo {
public static void main(String[] args) {
List<Participant> participants = List.of(
new Participant("Zhang San", 25, "Basics"),
new Participant("Li Si", 17, "Basics"),
new Participant("Wang Wu", 30, "Algorithms"),
new Participant("Zhao Liu", 22, "Basics"));
List<String> adultNames = participants.stream()
.filter(participant -> participant.age() >= 18)
.sorted(Comparator.comparing(Participant::name))
.map(participant -> "[" + participant.team() + "] " + participant.name())
.toList();
Map<String, Integer> oldestByTeam = participants.stream()
.collect(Collectors.toMap(
Participant::team,
Participant::age,
Integer::max,
TreeMap::new));
double averageAge = participants.stream()
.mapToInt(Participant::age)
.average()
.orElse(0.0);
adultNames.forEach(System.out::println);
System.out.println("Highest ages by team: " + oldestByTeam);
System.out.printf(java.util.Locale.ROOT, "Average age: %.1f%n", averageAge);
}
record Participant(String name, int age, String team) { }
}Execution method:
javac --release 17 -encoding UTF-8 StreamDemo.java
java StreamDemoExpected output:
[Algorithms] Wang Wu
[Basics] Zhang San
[Basics] Zhao Liu
Highest ages by team: {Algorithms=30, Basics=25}
Average age: 23.5This example collects into a TreeMap, so keys follow the natural String ordering of team names. It does not apply language-specific collation. If a map does not specify iteration order, do not turn one observed key order into a test requirement.
Before Proceeding
Draw a small pipeline to illustrate the source, intermediate operations, and terminal operations, and verify that merely building a pipeline without a terminal operation does not traverse the source. After a terminal operation, attempting to consume the same stream again must fail. Clearly document strategies for edge cases such as empty averages, duplicate map keys, and unmodifiable results from toList(). Business auditing must not be hidden within peek, and parallel streams must first demonstrate semantic correctness before performance is measured.
→ Next: Lambda and Stream Experiment
API Reference: Java 17 Stream Package Documentation