Java Streams Are Lazy — What That Means for Performance and Correctness
by Eric Hanson, Backend Developer at Clean Systems Consulting
What laziness means precisely
A Java stream pipeline has two kinds of operations: intermediate and terminal. Intermediate operations — filter, map, flatMap, sorted, distinct, limit, skip, peek — return a new stream. Terminal operations — collect, forEach, reduce, count, findFirst, anyMatch, toList — consume the stream and produce a result.
Intermediate operations do not execute when called. They register a transformation or predicate on the stream pipeline. Nothing is evaluated until a terminal operation is invoked.
Stream<Order> pipeline = orders.stream()
.filter(order -> {
System.out.println("filtering: " + order.getId());
return order.getTotal() > 1000;
})
.map(order -> {
System.out.println("mapping: " + order.getId());
return enrich(order);
});
// Nothing printed yet — pipeline is defined but not executed
List<Order> result = pipeline.collect(Collectors.toList()); // execution happens here
Until collect() is called, no element is filtered, no element is mapped, and no output is printed. The stream describes the computation; the terminal operation runs it.
How elements move through the pipeline
Elements move through the pipeline one at a time, not stage by stage. The first element passes through filter and map before the second element is processed. This is not a sequence of: (filter all elements) → (map all elements) → (collect). It is: (filter element 1) → (map element 1) → (filter element 2) → (map element 2) → ...
List<String> result = Stream.of("a", "bb", "ccc", "dddd")
.filter(s -> {
System.out.println("filter: " + s);
return s.length() > 1;
})
.map(s -> {
System.out.println("map: " + s);
return s.toUpperCase();
})
.collect(Collectors.toList());
// Output:
// filter: a
// filter: bb
// map: bb
// filter: ccc
// map: ccc
// filter: dddd
// map: dddd
"a" fails the filter and is dropped. The remaining elements each pass through filter then map before the next element is touched. This element-by-element execution is what enables short-circuiting.
Short-circuiting — stopping early
Short-circuit operations stop processing as soon as the result is determined. findFirst, findAny, anyMatch, allMatch, noneMatch, and limit are all short-circuit operations.
Optional<Order> first = orders.stream()
.filter(order -> order.getTotal() > 10_000)
.findFirst();
Without laziness, filter would process every order before findFirst could return. With lazy element-at-a-time execution, filter processes orders until one passes, then findFirst returns immediately — the rest of the stream is never examined.
For a stream of 1 million orders where the first matching order is at index 3, only 4 elements are processed. The performance difference is not marginal.
// Practical: checking if any order exceeds a threshold
boolean hasLargeOrder = orders.stream()
.anyMatch(order -> order.getTotal() > 50_000);
// Stops at the first match — does not process the entire stream
limit(n) is a short-circuit operation that caps the stream at n elements. Combined with sorted, it's the efficient way to get the top-n elements:
// Efficient: processes all elements for sorting, but collects only 5
List<Order> topFive = orders.stream()
.sorted(Comparator.comparingLong(Order::getTotal).reversed())
.limit(5)
.collect(Collectors.toList());
Note: sorted is a stateful intermediate operation — it must see all elements before it can emit the first sorted element. Short-circuiting with limit applies after sorted, not before. For truly efficient top-n on large streams, a PriorityQueue of size n is more efficient than sort + limit because it doesn't sort the entire dataset.
Infinite streams
Laziness makes infinite streams possible. A stream that generates values indefinitely is only evaluated as far as the terminal operation demands:
// Infinite stream of integers starting at 1
Stream.iterate(1, n -> n + 1)
.filter(n -> n % 2 == 0) // even numbers
.mapToLong(n -> (long) n * n) // squares
.limit(10) // take only 10
.forEach(System.out::println);
// Prints: 4, 16, 36, 64, 100, 144, 196, 256, 324, 400
Without limit, this would run forever. The terminal operation forEach pulls elements through the pipeline; limit stops it after 10 elements are emitted. Without laziness, Stream.iterate couldn't return a stream at all — it would need to generate all values before any filtering.
// Finding the first prime above 1000
OptionalLong firstPrime = LongStream.iterate(1001, n -> n + 2)
.filter(Primes::isPrime)
.findFirst();
LongStream.iterate generates odd numbers indefinitely. filter tests each for primality. findFirst stops at the first match. No bound needed — laziness handles it.
Operation fusion — the JVM optimization that laziness enables
Because the stream pipeline is built as a description before execution, the JVM can fuse adjacent operations. In practice, filter().map() doesn't create two separate traversals — the JVM combines them into one pass where each element is filtered and mapped in a single operation.
This is visible in the element-at-a-time execution above. The mechanism: each intermediate operation is represented as a stage in a linked pipeline. During execution, each stage calls the next stage's accept method. The chain is essentially inlined into a single loop by the JIT — no intermediate collection is allocated between stages.
Contrast with Collection operations that aren't lazy:
// Not lazy — allocates two intermediate lists
List<Order> filtered = orders.stream().filter(pred).collect(toList());
List<String> mapped = filtered.stream().map(f).collect(toList());
// Lazy — one pass, no intermediate list
List<String> result = orders.stream().filter(pred).map(f).collect(toList());
The lazy version allocates one result list. The eager version allocates the intermediate filtered list in addition to the final result. For large streams, this is significant GC pressure.
Where laziness causes correctness bugs
Laziness is a feature for performance and enables infinite streams. It's a source of bugs when code assumes intermediate operations have already executed.
Side effects in intermediate operations. A common mistake: using peek or map for side effects and assuming they've fired:
List<String> processed = new ArrayList<>();
Stream<Order> stream = orders.stream()
.peek(order -> processed.add(order.getId())); // not yet executed
// Later in the code, assuming processed is populated:
if (processed.isEmpty()) {
log.warn("No orders processed"); // always true — stream hasn't run yet
}
stream.collect(Collectors.toList()); // processed is populated HERE
The processed list is empty until the terminal operation runs. If the check happens before the terminal operation, it's always empty. Side effects in intermediate operations must be understood as deferred.
Stream reuse. A stream can only be consumed once. A second terminal operation on the same stream throws IllegalStateException:
Stream<Order> stream = orders.stream().filter(Order::isPending);
long count = stream.count(); // terminal — stream is consumed
List<Order> list = stream.collect(toList()); // IllegalStateException
This is a consequence of laziness and single-pass execution — once the pipeline has run, there's no way to rerun it without the source. Assign the stream to a variable only when you have one terminal operation in mind. If you need multiple operations, either use the source collection directly or store the result of the first terminal operation and operate on it.
Lazy evaluation with mutable sources. If the source collection is modified while the stream is open (terminal operation hasn't run yet), behavior is undefined. Streams are not designed for concurrent modification:
List<Order> orders = new ArrayList<>(fetchOrders());
Stream<Order> stream = orders.stream().filter(Order::isPending);
orders.add(newOrder); // modifying the source before the terminal operation
stream.collect(toList()); // may or may not include newOrder — undefined behavior
The ConcurrentModificationException you'd get with an iterator is not guaranteed with streams — the behavior is simply undefined. Build the stream and run the terminal operation without modifying the source in between.
The rule of thumb
Intermediate operations are specifications, not executions. Everything before the terminal operation is a description of what to do, not a record of what has been done. Code that treats intermediate operations as having already run — checking side-effect results before terminal operations, assuming the stream has been consumed, modifying the source between stream creation and execution — will behave incorrectly.
The model to internalize: a stream pipeline is a function from source to result, triggered by the terminal operation. The intermediate operations are the function body. Nothing in the function body runs until the function is called.