Skip to content

VOL 01 / CH 08 / LESSON 07

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.

java
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 ​

text
source               intermediate operations                        terminal operations
participants.stream() -> filter -> map -> sorted -> limit -> toList
PartPurposeCommon Examples
SourceProvides elementslist.stream(), Stream.of(...), Files.lines(...)
Intermediate OperationsReturns a new Stream, composing processing stepsfilter, map, distinct, sorted, limit
Terminal OperationsTriggers traversal and produces a result or side effecttoList, 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:

java
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:

java
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 method

The 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 ​

java
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();
  • filter retains elements that satisfy a predicate;
  • map transforms each element into another value;
  • flatMap flattens multiple values produced by each element into a single stream;
  • distinct removes duplicates based on equals/hashCode;
  • sorted requires a natural ordering or an explicit Comparator;
  • limit and skip slice the sequence;
  • takeWhile and dropWhile process 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 ​

java
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:

java
List<String> names = participants.stream()
        .map(Participant::name)
        .toList();

// names.add("New participant"); // UnsupportedOperationException

If a mutable result is explicitly required, you can write:

java
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:

java
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 ​

java
Stream<String> stream = List.of("Status", "Weather").stream();
long count = stream.count();
// stream.findFirst(); // IllegalStateException

After 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:

java
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:

java
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.

java
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:

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:

bash
javac --release 17 -encoding UTF-8 StreamDemo.java
java StreamDemo

Expected output:

text
[Algorithms] Wang Wu
[Basics] Zhang San
[Basics] Zhao Liu
Highest ages by team: {Algorithms=30, Basics=25}
Average age: 23.5

This 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

Built with VitePress | Software Systems Atlas