Stream API
Stream pipelines — intermediate and terminal operations, laziness, map vs flatMap, collectors, reduce vs collect, parallel streams, and gatherers.
12 questions
JuniorTheoryVery commonWhat is a Stream, and how does it differ from a for-loop over a collection?
What is a Stream, and how does it differ from a for-loop over a collection?
A Stream is a pipeline over a sequence of elements, not a container that stores them. It has a source (a collection or an array), intermediate operations such as filter and map, and one terminal operation such as collect. Unlike a loop it declares what to compute rather than how to iterate, and it never mutates its source — it produces new results.
Common mistakes
- ✗Thinking a Stream stores its elements the way a
Listdoes - ✗Believing
filterormapmodify the source collection - ✗Chaining intermediate operations and expecting work to happen without a terminal operation
Follow-up questions
- →Which parts of a stream pipeline are lazy, and what actually triggers the work?
- →Why can you not traverse the same stream instance twice?
JuniorTheoryVery commonHow do intermediate and terminal stream operations differ?
How do intermediate and terminal stream operations differ?
An intermediate operation like filter or map returns another Stream, so it merely adds a stage to the pipeline. A terminal operation like collect or count returns a non-stream result and is what actually runs the pipeline. Nothing at all is computed until that terminal call arrives — the intermediate stages are lazy by design.
Common mistakes
- ✗Expecting
filterormapto run at the moment they are called - ✗Building a chain of intermediate operations and never calling a terminal one
- ✗Assuming a terminal operation returns a
Streamyou can keep chaining
Follow-up questions
- →Why does a stream with only intermediate operations produce no output at all?
- →How does laziness let a short-circuiting terminal operation skip elements?
MiddleCodeCommonFind the bug in this Optional chain
Find the bug in this Optional chain
It is a compile-time error, not runtime. map(authService::findClient) wraps the already-Optional result, producing Optional<Optional<Client>>. So in the next map, client is an Optional<Client>, not a Client, and passing it to createResponse(Client) fails to type-check. Fix it by using flatMap for findClient: flatMap unwraps one level, yielding Optional<Client> so the next map receives a real Client.
Common mistakes
- ✗Calling the error a runtime fault when it is caught at compile time
- ✗Missing that
mapover anOptional-returning function nests toOptional<Optional<T>> - ✗Reaching for
.get()instead offlatMapto flatten one level
Follow-up questions
- →When do you use
mapversusflatMapon anOptional? - →Why is
Optional<Optional<T>>almost always a code smell?
MiddleTheoryCommonHow does parallelStream run, and when does it go wrong?
How does parallelStream run, and when does it go wrong?
parallelStream splits the source and runs stages on the common ForkJoinPool (sized to the cores), then combines partial results. It speeds up CPU-bound work on large, splittable sources but hurts small or I/O-bound ones. It silently breaks correctness when reduce uses a non-identity seed or a non-associative accumulator, or when a lambda mutates shared state — each thread re-applies the seed and ordering is lost.
Common mistakes
- ✗Assuming
parallelStreamalways speeds things up, including on small or I/O-bound work - ✗Using a non-identity seed or non-associative accumulator in a parallel
reduce - ✗Mutating shared state from a stream lambda and expecting deterministic results
Follow-up questions
- →Why must the
reduceidentity be a true identity element under parallelization? - →Why can the common
ForkJoinPoolmake a blocking task in a parallel stream dangerous?
MiddleTheoryCommonWhat is the Stream API pipeline, and when does it actually execute?
What is the Stream API pipeline, and when does it actually execute?
The Stream API processes a sequence of elements declaratively as a pipeline: a source, intermediate operations like filter and map, and a terminal operation like collect. Intermediate operations are lazy — they build the pipeline without iterating; only a terminal operation triggers execution, streaming each element through. A stream can also run in parallel across threads.
Common mistakes
- ✗Believing intermediate operations run immediately rather than building a lazy pipeline
- ✗Reusing a stream after its terminal operation, which throws
IllegalStateException - ✗Thinking stream operations mutate the source collection instead of producing new results
Follow-up questions
- →How does a short-circuiting terminal operation like
findFirststop early? - →What thread pool backs a parallel stream, and when is parallelism worthwhile?
MiddleCodeCommonCount orders per status with Collectors.groupingBy
Count orders per status with Collectors.groupingBy
groupingBy(classifier) applies the classifier to each element and collects elements into a Map<K, List<T>> keyed by its result. The two-arg groupingBy(classifier, downstream) feeds each bucket into a second collector instead of listing the elements, so groupingBy(Order::status, counting()) yields a Map<Status, Long> of counts in one stream pass: orders.stream().collect(groupingBy(Order::status, counting())).
Common mistakes
- ✗Expecting a plain
Map<K, List<T>>and then looping again to count instead of passingcounting() - ✗Thinking
groupingBysorts keys or needs aComparablekey likeTreeMapdoes - ✗Believing the downstream collector changes the keys rather than reducing each value bucket
Follow-up questions
- →How does
groupingBy(classifier, mapping(...))differ fromgroupingBy(classifier, counting())? - →When would you pass a map supplier as the third argument to
groupingBy?
MiddleTheoryCommonHow does reduce differ from collect, and when do you pick each?
How does reduce differ from collect, and when do you pick each?
reduce is an immutable fold: it applies a BinaryOperator to an identity and the running value, producing a new value each step. collect is a mutable reduction: a supplier creates a container, an accumulator folds each element into it, and a combiner merges two containers. Use reduce for a value like a sum or max; use collect when the result is a container, because folding a String per element allocates O(n) garbage.
Common mistakes
- ✗Folding a
Stringwithreduceand paying O(n) copies instead of collecting into aStringBuilder - ✗Passing a non-associative accumulator or a non-neutral identity to
reduce, which breaks under parallelism - ✗Thinking
collectneeds external synchronization, when each thread gets its own container merged by the combiner
Follow-up questions
- →Why must the identity passed to
reducebe a true neutral element once the stream goes parallel? - →Which three functions does a mutable reduction need, and what is the combiner's job?
SeniorCodeOccasionalWrite a custom Collector that keeps the n greatest elements
Write a custom Collector that keeps the n greatest elements
Collector.of(supplier, accumulator, combiner, finisher) builds one. The supplier makes a fresh mutable container per worker, the accumulator folds one element into it, the combiner merges two containers — that is what makes it parallel-safe — and the finisher turns the container into the result. For topN the container is a min-heap: push each element, drop the smallest once it holds more than n, and sort the survivors in the finisher. Memory stays O(n).
Common mistakes
- ✗Writing a combiner that drops one side's partial result, so the parallel answer differs from the sequential one
- ✗Buffering every element and sorting at the finisher, which makes memory O(input) instead of O(n)
- ✗Returning the mutable container itself instead of converting it in the finisher
Follow-up questions
- →What does the
UNORDEREDcharacteristic promise, and when is it safe to declare it? - →When does
IDENTITY_FINISHapply, and what does it save the pipeline?
SeniorPerformanceOccasionalHow do short-circuiting terminals like findFirst and anyMatch bound a pipeline's work?
How do short-circuiting terminals like findFirst and anyMatch bound a pipeline's work?
Laziness pulls elements one at a time, so a short-circuiting terminal — findFirst, anyMatch, allMatch — stops pulling the moment the answer is settled: filter(p).findFirst() over a million elements tests p only until the first match. That is also what makes an infinite Stream.iterate usable behind limit. The catch is order: on a parallel ordered stream findFirst owes you the encounter-order-first match, unlike findAny.
Common mistakes
- ✗Believing each stage materializes a full intermediate collection before the next one runs
- ✗Expecting
findFirstto cost the same asfindAnyon a parallel ordered stream - ✗Thinking an infinite stream cannot be consumed at all, rather than bounded by a short-circuiting stage
Follow-up questions
- →Why can
allMatchreturnfalsewithout ever inspecting the remaining elements? - →When is
findAnymeasurably cheaper thanfindFirst, and what guarantee do you give up?
SeniorTheoryOccasionalWhy does reusing a stream after its terminal operation throw IllegalStateException?
Why does reusing a stream after its terminal operation throw IllegalStateException?
A stream is not a container: it holds a source spliterator plus the pipeline stages and traverses that source exactly once. The pipeline carries a linked-or-consumed flag that the terminal operation sets, so any later operation throws IllegalStateException. Nothing was buffered to replay — the spliterator cannot be rewound. To traverse twice, rebuild the stream from the source or keep a Supplier<Stream<T>>.
Common mistakes
- ✗Storing a
Streamin a field or a variable and handing it to two terminal operations - ✗Thinking laziness means the pipeline buffers elements it could replay
- ✗Expecting the exception only when the source is a file or a socket, never a
List
Follow-up questions
- →How does a
Supplier<Stream<T>>let a caller traverse the same pipeline twice? - →Why does attaching a second intermediate operation to an already-consumed stream fail as well?
SeniorDebuggingOccasionalWhy does this Collectors.toMap throw IllegalStateException, and how do you fix it?
Why does this Collectors.toMap throw IllegalStateException, and how do you fix it?
The two-argument toMap(keyMapper, valueMapper) has no rule for two elements landing on the same key, so the second Sales employee makes it throw. Use the three-argument overload and give it a merge function — toMap(Employee::department, Employee::salary, Integer::sum) — which folds colliding values instead of failing. groupingBy(Employee::department, summingInt(...)) is the equivalent.
Common mistakes
- ✗Assuming
toMapoverwrites a duplicate key the wayMap.putdoes, instead of throwing - ✗Reaching for
distinct()or a pre-filter rather than telling the collector how to merge colliding values - ✗Forgetting that
toMapalso throwsNullPointerExceptionwhen the value mapper returnsnull
Follow-up questions
- →When would you prefer
groupingBywith a downstream collector over a three-argumenttoMap? - →How do you make
toMapreturn aTreeMapor aLinkedHashMapinstead of aHashMap?
MiddleTheoryRareWhat is a Gatherer, and what does it let you do that a Collector cannot?
What is a Gatherer, and what does it let you do that a Collector cannot?
A Gatherer (final in Java 24, JEP 485) is a user-written intermediate operation — the Collector counterpart for the middle of a pipeline, plugged in with stream.gather(g). Built from an initializer, an integrator, an optional combiner and a finisher, it can carry state across elements, emit zero, one or many outputs per input, and short-circuit the upstream. A Collector reduces only once, at the terminal step.
Common mistakes
- ✗Taking a
Gathererfor a terminal operation like aCollectorrather than an intermediate stage - ✗Assuming an intermediate stage must emit exactly one element per input, so windowing needs a
collectfirst - ✗Missing that the integrator can return
falseto short-circuit the upstream source
Follow-up questions
- →Which ready-made gatherers does
java.util.stream.Gatherersship, and what doesscando? - →How does a
Gatherer's combiner make a stateful stage usable in a parallel stream?