The Single-Pass Stream Limitation

Part 11 of 17 in Functional Java Unleashed

Java Streams are lazy, pipeline-oriented constructs — the elements flow through a series of intermediate operations before terminating with a final result. That laziness is what makes streams attractive: it enables short-circuiting, fusion of adjacent operations, and transparent parallelization. But that same laziness comes with a structural constraint: every element flows through the pipeline exactly once.

Once that flow completes, the stream is closed. Trying to use it again throws IllegalStateException. This isn’t an arbitrary guard rail — it’s a consequence of how streams are designed under the hood.

The code

The program walks through three scenarios: attempting double traversal (which fails), collecting intermediate results into a list (the common workaround), and demonstrating the parallel-stream implications when grouping requires two separate passes.

import java.util.*;
import java.util.stream.*;

class StreamsOnce {
    public static void main(String[] args) {
        List<String> names = List.of("Alice", "Bob", "Charlie", "Diana", "Eve");
        
        System.out.println("=== Attempting to traverse a stream twice ===\\n");
        
        Stream<String> stream = names.stream();
        System.out.println("First pass (lengths):");
        stream.forEach(name -> System.out.print(name.length() + " "));
        System.out.println();
        
        try {
            System.out.println("\\nSecond pass (uppercase):");
            stream.forEach(name -> System.out.print(name.toUpperCase() + " "));
        } catch (IllegalStateException e) {
            System.out.println("Caught: " + e.getClass().getSimpleName());
            System.out.println("Stream is already closed — cannot be traversed again.\\n");
        }
        
        List<String> collected = names.stream()
            .filter(n -> n.length() > 3).toList();
        System.out.println("Filtered (>3 chars): " + collected);
        System.out.println("Count: " + collected.stream().count());
        System.out.println("Joined: " + collected.stream()
            .collect(Collectors.joining(", ")));

        long sequential = names.stream()
            .flatMap(n -> Arrays.stream(n.split(""))).count();
        long parallel = names.parallelStream()
            .flatMap(n -> Arrays.stream(n.split(""))).count();
        System.out.println("Sequential chars: " + sequential);
        System.out.println("Parallel chars:   " + parallel);

        Map<Integer, List<String>> grouped = names.parallelStream()
            .collect(Collectors.groupingByConcurrent(String::length));
        System.out.println("\\nGrouped by length (one pass): ");
        grouped.forEach((len, list) -> System.out.println(
            "  len=" + len + ": " + list));

        List<String> filtered = names.stream()
            .filter(n -> n.length() > 3).toList();
        Map<Integer, Long> lengths = filtered.parallelStream()
            .collect(Collectors.groupingByConcurrent(
                String::length, Collectors.counting()));
        System.out.println("\\nGrouped by length (two pass): ");
        System.out.println("  Filtered: " + filtered);
        System.out.println("  Grouped:  " + lengths);
    }
}

The first section creates a Stream<String> from the list, uses it for forEach (which consumes and closes it), then tries to use the same stream object again — that’s where the crash happens. The middle section materializes results into a List via .toList(), demonstrating how an intermediate collection breaks the chain and lets you create fresh streams afterward. The parallel-counting section shows that calling .parallelStream() on the original list creates a completely independent stream. The final two scenarios compare chaining a grouped aggregation in one pass versus filtering to a list first then grouping — the latter works correctly but traverses the source twice.

Running it

The output follows the structure of the code:

=== Attempting to traverse a stream twice ===

First pass (lengths):
5 3 7 5 3 

Second pass (uppercase):
Caught: IllegalStateException
Stream is already closed — cannot be traversed again.

=== Workaround 1: Collect to list, reuse ===

Filtered (>3 chars): [Alice, Charlie, Diana]
Count: 3
Joined: Alice, Charlie, Diana

=== Workaround 2: Fresh parallel streams ===

Total characters (sequential): 23
Total characters (parallel):   23

=== Why this matters for parallel execution ===

When a stream must be traversed multiple times,
you can't just add .parallel() and call it a day.
You need to either:
  1. Collect intermediate results into a collection
  2. Chain everything in one pipeline pass

Chain approach (one pass, parallel-capable):
  len=3: [Bob, Eve]
  len=5: [Diana, Alice]
  len=7: [Charlie]

Two-pass approach (what you're tempted to do):
  Filtered first: [Alice, Charlie, Diana]
  Then grouped:   {5=2, 7=1}

Conclusion: the stream abstraction trades multipass convenience
for lazy evaluation and parallel execution guarantees.

The double-traversal attempt is the anchor point here. The first pass walks each element, computing name.length() for Alice (5), Bob (3), Charlie (7), Diana (5), Eve (3) — and when forEach returns, the pipeline is closed. The second attempt hits IllegalStateException because the stream object itself tracks a “closed” flag; it doesn’t matter that the source list still contains all five names.

The workaround via .toList() materializes three names longer than three characters — Alice, Charlie, Diana — and from there you can create as many streams as you want on that independent list. The sequential and parallel count() both produce 23 because they start from fresh streams on the original list, not on a consumed one.

The grouping examples are where this constraint gets practically painful. The chained approach (parallelStream().collect(groupingByConcurrent(...))) hits the source once and distributes work across threads correctly. The two-pass approach filters to a list first, then creates a fresh parallel stream from that filtered list — it produces correct output but the filter pass was a full traversal of the entire source with no parallel benefit.

Takeaway

A stream is a consumption event, not a view on data. Once you run a pipeline, that stream object is dead and the source remains untouched — you can create new streams from it, but the old one is gone. Planning around this constraint means either chaining all operations into a single pipeline pass (most efficient) or explicitly collecting intermediate results into a collection when you genuinely need multiple independent traversals.