Stream API Fundamentals: Lazy Pipelines
How a stream pipeline actually executes: why nothing runs until the terminal operation, what short-circuiting really means, stateful versus stateless operations, and why a stream is single-use.
On this page
Most stream bugs and most stream interview questions come from one misunderstanding: people picture a stream as a sequence of collection transformations, each producing a full intermediate result. It is not. It is a single pass, pulled element by element, and nothing happens until the end.
Key Takeaways
- Intermediate operations are lazy and return a stream. Terminal operations trigger execution and return something else.
- Elements flow vertically: one element through the whole pipeline, then the next.
- Short-circuiting operations can stop early —
findFirst,anyMatch,limit. - A stream is single-use. A second terminal operation throws
IllegalStateException. - Order matters:
filterbeforemapmeans the mapping function runs fewer times.
The three parts of a pipeline
List<String> result = orders.stream() // SOURCE
.filter(o -> o.total() > 100) // intermediate — lazy
.map(Order::customerName) // intermediate — lazy
.distinct() // intermediate — stateful
.sorted() // intermediate — stateful, full barrier
.limit(10) // intermediate — short-circuiting
.toList(); // TERMINAL — executes everythingDelete the last line and the code does nothing at all. The intermediate calls build a linked description of the work and return immediately. This is the single most useful thing to be able to state clearly.
Vertical, not horizontal
Stream.of("alpha", "beta", "gamma")
.filter(s -> { System.out.println("filter: " + s); return s.length() > 4; })
.map(s -> { System.out.println(" map: " + s); return s.toUpperCase(); })
.forEach(s -> System.out.println(" out: " + s));filter: alpha
map: alpha
out: ALPHA
filter: beta
filter: gamma
map: gamma
out: GAMMANotice what did not happen: the filter did not run over all three elements before the map started,
and beta never reached the map at all. Each element is pushed all the way through before the next
one is fetched.
Two consequences follow directly. First, short-circuiting is possible — a terminal operation can stop asking for elements. Second, operation order is a performance decision:
// map runs 1,000,000 times, filter discards most results
orders.stream().map(this::expensiveEnrichment).filter(Order::isFlagged).toList();
// map runs only on the flagged subset
orders.stream().filter(Order::isFlagged).map(this::expensiveEnrichment).toList();Short-circuiting
Optional<Order> first = orders.stream() // 1,000,000 orders
.filter(o -> o.total() > 100)
.findFirst(); // stops at the first matchThe short-circuiting operations are findFirst, findAny, anyMatch, allMatch, noneMatch,
limit and takeWhile. They are what make infinite streams usable:
Stream.iterate(1, n -> n * 2).limit(10).toList(); // 1, 2, 4, ... 512
Stream.generate(Math::random).limit(5).toList();
Stream.iterate(1, n -> n < 1000, n -> n * 2).toList(); // Java 9: built-in predicate
// Without limit or a predicate, this never terminates.Note that allMatch on an empty stream returns true — vacuous truth — and anyMatch returns
false. That pair is a reliable interview trip-up.
Stateless, stateful and barriers
| Kind | Operations | Behaviour |
|---|---|---|
| Stateless | filter, map, flatMap, peek | Each element handled independently |
| Stateful, bounded | distinct, skip, limit | Needs memory of what came before |
| Stateful, full barrier | sorted | Must consume the entire source before emitting anything |
sorted() is the one worth calling out. It breaks the one-element-at-a-time model completely: it
buffers everything, sorts, then emits. Putting sorted() before limit(10) on a large stream sorts
the whole thing; the JDK does optimise the sorted().limit(n) combination for sized sources, but the
general rule — filter and limit before you sort — still holds.
distinct() on an ordered parallel stream is also expensive, because preserving encounter order
requires coordination. Calling .unordered() first, when order genuinely does not matter, removes
that cost.
Single use
Stream<Order> stream = orders.stream();
long count = stream.count();
List<Order> list = stream.toList();
// IllegalStateException: stream has already been operated upon or closedA stream is not a collection. It may be backed by a file, a network socket or a generator, none of which can be rewound. Rather than pretend, the API fails loudly.
When you need two results from one traversal, the answers are: collect once and derive both from the
collection; use a Collector that computes both (teeing, or a summary statistics collector); or —
if the source is a collection — simply call .stream() twice.
IntSummaryStatistics stats = orders.stream().mapToInt(Order::quantity).summaryStatistics();
stats.getCount(); stats.getMax(); stats.getAverage();Spliterator: where the elements come from
Every stream is driven by a Spliterator — "splittable iterator". It does three jobs: advance one
element (tryAdvance), split itself in half for parallel processing (trySplit), and report
characteristics such as SIZED, ORDERED, DISTINCT and SORTED.
Those characteristics are not decoration; the pipeline uses them to skip work. A stream from a
TreeSet reports SORTED, so sorted() becomes a no-op. A stream from a HashSet reports
DISTINCT, so distinct() is free. A SIZED source lets toArray allocate exactly once.
Stream<String> lines = StreamSupport.stream(
Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED | Spliterator.NONNULL),
false);You rarely write one, but knowing the name and the three responsibilities is a strong signal in an
interview, and it is the natural bridge into
parallel streams — where trySplit is the whole
story.
Side effects and peek
// 1. Mutating external state from a stream: not thread-safe, and defeats the model.
List<String> names = new ArrayList<>();
orders.stream().forEach(o -> names.add(o.name())); // use .map(...).toList()
// 2. peek() for anything other than debugging.
orders.stream().peek(this::audit).toList();peek is documented as existing "mainly to support debugging". Since Java 9 the pipeline may skip it
entirely when the element count can be determined without traversal — list.stream().peek(print).count()
prints nothing, because count() reads the size directly. Anything with a real side effect belongs in
forEach or, better, outside the stream.
The answers to have ready
"Intermediate operations are lazy and return a stream; terminal operations execute the pipeline.
Elements are pulled one at a time all the way through, which is what makes short-circuiting possible
and why filter before map is faster. A stream is single-use because the source may not be
replayable."
Follow-ups to expect: what sorted() does to laziness, why peek sometimes prints nothing, and what
happens if you call two terminal operations. All three are answered by the same model.
Frequently Asked Questions
Why does a stream with no terminal operation print nothing?
What does IllegalStateException stream has already been operated upon or closed mean?
Do streams process one element at a time or one operation at a time?
Related tutorials
- Functional Interfaces: Function, Predicate, Supplier, ConsumerThe four core shapes and how to derive the other thirty-nine, why primitive specialisations exist, compose versus andThen, and the checked-exception problem with a clean workaround.
- Collectors, groupingBy and Downstream CollectorsThe collector API in depth: multi-level groupingBy, downstream collectors, the toMap duplicate-key exception, the null-value trap, teeing and flatMapping, and writing a Collector by hand.
- Lambdas and invokedynamic: How They Really WorkWhy a lambda is not an anonymous class, what invokedynamic and LambdaMetafactory do at first call, the allocation difference between capturing and non-capturing lambdas, and what this means.
- Parallel Streams and the Common ForkJoinPoolWhy every parallel stream in your JVM shares one pool, which sources split well, the N times Q rule for deciding, and why a blocking call inside a parallel stream can stall the whole application.