Streams are lazy until a terminal operation runs. Why does that matter, and when would you make one parallel?
Laziness means intermediate operations only describe a pipeline, so nothing traverses the source until a terminal operation pulls, and short-circuiting operations can stop early. Parallel helps when the source splits evenly, the work per element is CPU-bound and substantial.
What the interviewer is scoring
- Whether laziness is explained as pull-driven traversal rather than as "it is optimised"
- Does the candidate know parallel streams share one pool by default, and what that implies for blocking work
- That they distinguish a source that splits cheaply from one that does not
- Whether they treat a collector's characteristics as part of the parallel decision
- Can they name a readability limit and mean it, rather than defending every pipeline they have written
Answer
Nothing runs until something pulls
filter, map and sorted return a new stream and traverse nothing. They record a stage. Only a terminal operation — collect, forEach, reduce, findFirst — starts the traversal, and it drives the whole pipeline element by element rather than stage by stage. One element passes through every stage before the next element is fetched.
Two consequences follow, and the interviewer wants both. First, short-circuiting works: stream.filter(expensive).findFirst() evaluates expensive until the first match and then stops, which a collection-per-stage implementation could never do. Second, a pipeline with no terminal operation does absolutely nothing, which is a real bug when someone writes list.stream().map(this::save) and wonders why nothing was saved.
The exception to element-at-a-time flow is a stateful operation. sorted and distinct are barriers: sorted has to consume the entire upstream before it can emit anything, and distinct has to retain what it has seen. Putting sorted before limit(10) still forces a full sort of everything upstream, which is why ordering the stages matters more than it looks.
Collectors are where the decisions hide
collect is the part of the API where you choose semantics, and the defaults are opinionated in ways worth knowing. Collectors.toList() returns a list of unspecified mutability, whereas Stream.toList(), added in JDK 16, is specified to return an unmodifiable list that permits null elements. Collectors.toUnmodifiableList() is also unmodifiable but rejects nulls. Three near-identical calls, three different contracts.
toMap is the sharp one:
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
record Order(String customerId, long amountMinor, boolean paid) { }
final class Reports {
static Map<String, Long> totalPerCustomer(List<Order> orders) {
return orders.stream()
.filter(Order::paid)
// The two-argument toMap throws IllegalStateException the first
// time two orders share a customerId. The merge function is the
// whole point of using the three-argument form, not a tweak.
.collect(Collectors.toMap(Order::customerId, Order::amountMinor, Long::sum));
}
}
toMap also throws NullPointerException if a value mapper returns null, because it goes through Map.merge. groupingBy has neither problem, which is one reason it is the safer default when the keys are not known to be unique.
What makes parallel worth it
parallel() is one method call, which is exactly why it gets used badly. Four conditions have to hold together.
The source must split cheaply and evenly. ArrayList, arrays and IntStream.range split by index in constant time. LinkedList, a HashSet with a bad distribution, Files.lines and anything built from an Iterator split poorly or only after traversal, so the parallel version does the same work plus coordination.
The per-element work must be substantial and CPU-bound. Splitting, task scheduling and merging have a fixed cost; a pipeline that increments an integer will lose to the sequential version at any size.
The pipeline must not block. By default a parallel stream runs on the common ForkJoinPool, whose parallelism is one less than the number of available processors, with the submitting thread also contributing. That pool is shared by every parallel stream in the JVM, so one pipeline making HTTP calls inside map starves every other user of it. If you must confine the work, submit the whole terminal operation as a task to your own ForkJoinPool — but understand that this relies on where fork-join tasks execute rather than on any documented guarantee about stream pools, so it is a workaround and not a design.
Finally, the accumulation must not be a bottleneck. Collecting in parallel into an ordinary groupingBy builds a map per thread and merges them; the merge is real work and can dominate. groupingByConcurrent accumulates into one concurrent map instead, but only pays off when the stream is unordered, because a concurrent collector on an ordered stream loses its advantage.
Correctness traps that survive review
Shared mutable state is the obvious one, and it usually appears disguised: a lambda adding to an ArrayList declared outside the pipeline is a data race in parallel and merely poor style in sequential, so it passes every test until someone adds parallel() three years later.
reduce requires the accumulator to be associative and the identity to be a genuine identity. Subtraction and string concatenation with a non-empty seed both look fine sequentially and produce different answers per run in parallel.
peek is the trap most people have not met. It is specified for debugging, and because the pipeline only computes what the terminal operation demands, a peek stage can be skipped entirely — since JDK 9, count() may bypass the pipeline altogether when the size is derivable without traversal. Side effects in peek are therefore not merely bad practice, they are unreliable.
And streams are one-shot. Reusing one throws IllegalStateException complaining that the stream has already been operated upon or closed, so a method that hands out a stream is handing out a single traversal. If the caller may need two, return the collection.
The readability line
Somewhere past three stages plus a nested lambda, a pipeline stops being clearer than the loop it replaced. The signal to watch for is a lambda that needs a {} body with control flow inside it: at that point you are writing a method, and it should be a method with a name. A pipeline where every stage is a short method reference reads well at almost any length; one where every stage is an inline block reads worse than an ordinary for loop and debugs considerably worse, because there is no line to put a breakpoint on and the stack trace is full of framework frames.
The answer that scores is the one that treats parallel() as a measurement, not an idiom. You do not reason your way to it. You have a benchmark, you have a pipeline that satisfies the four conditions above, and you have a number showing the parallel version is faster on the machine you will deploy to.
Likely follow-ups
- Why can a side effect inside peek fail to run at all?
- Collectors.toMap throws on a duplicate key. What else does it reject that groupingBy accepts?
- What is the difference between forEach and forEachOrdered on a parallel stream?
- Why is a stream one-shot, and what do you return from a method instead when the caller may need to traverse twice?
Related questions
- Why would you stream a large file rather than read it into memory, and what is backpressure?mediumAlso on streams4 min
- How do generators reduce memory use, and when are they the wrong choice?mediumAlso on laziness4 min
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardSame kind of round: concept7 min
- How do you tell whether a value escapes to the heap, and how would you find the allocations that are costing you?hardSame kind of round: concept6 min
- Adding a second LEFT JOIN doubled the revenue figure on a report. Explain what happened and write the correct query.mediumSame kind of round: coding4 min
- How do you decide between BFS and DFS - and how do you recognise a graph problem that nobody described as a graph?mediumSame kind of round: coding4 min
- A client disconnects but your server keeps working on their request. How does cancellation actually propagate in .NET?mediumSame kind of round: concept4 min
- Count the subarrays whose elements sum to k, where the values may be negative.mediumSame kind of round: coding4 min