Stream lazy evaluation and loop fusion, terminal operations and why streams are single-use, short-circuiting (limit, anyMatch), reduce with identity/accumulator/combiner, sequential vs parallel performance, parallel streams with shared state and with I/O, multi-field sorting, toMap vs groupingBy (and duplicate keys), custom collectors, peek, flattening nested lists, the second-highest number, and whether intermediate operations change stream size.
Published September 25, 2026
Senior stream questions test two things:
Give the idiomatic code, and the trap.
Short answer: Intermediate operations (filter, map, sorted, …) only build a pipeline description. Nothing runs until a terminal operation is invoked. Then the elements are pulled one at a time through the whole chain (loop fusion), rather than each stage processing the whole collection:
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(); })
.findFirst();
// prints: filter a, filter bb, map bb → stops. "ccc" and "dddd" are never touched
The benefits:
Stream.iterate(...).limit(n)).The exceptions: stateful operations like sorted() and distinct() must see (or buffer) all the upstream elements before emitting.
Learn it in depth → Streams API
collect(Collectors.toList()) a terminal operation?Short answer: Because it consumes the stream and produces a non-stream result: a List. Terminal operations trigger the pipeline's execution, and close the stream. collect performs a mutable reduction:
Key points to cover:
stream.toList() returns an unmodifiable list, and is shorter.Collectors.toList() makes no guarantee about the list type or mutability (it's currently an ArrayList).toCollection(ArrayList::new) when you need a mutable list.Short answer: A stream is a one-shot pipeline over a data source, not a data structure. Once a terminal operation has run, the source (an iterator, an I/O channel, a generator) may be consumed, and the pipeline's internal state is finished. Reusing it throws IllegalStateException: stream has already been operated upon or closed. This design lets streams wrap non-repeatable sources, like files, sockets or generators. To reuse the logic, keep a Supplier<Stream<T>>, or re-create the stream from the collection.
limit() and anyMatch()?Short answer: Short-circuiting operations can finish without processing all the elements:
anyMatch, allMatch, noneMatch, findFirst, findAny. anyMatch stops at the first true, and allMatch at the first false.limit(n), takeWhile(pred) (Java 9). Once satisfied, they signal upstream to stop pulling.Because evaluation is lazy and element-at-a-time, upstream operations run only for the elements actually needed. That's what makes Stream.iterate(1, n -> n + 1).filter(isPrime).limit(10) terminate. In parallel ordered streams, limit and findFirst are expensive: they must respect encounter order. unordered() or findAny are cheaper.
reduce() works, with identity, accumulator and combiner.Short answer: reduce(identity, accumulator, combiner) folds the elements into one result:
0 for a sum, "" for concatenation). For correctness in parallel, combiner(identity, x) == x.(partialResult, element) → newPartial: folds one element in.(partial1, partial2) → merged: merges the partial results from parallel sub-streams. It's needed when the result type differs from the element type, and must be compatible with the accumulator.int totalQty = orders.stream().reduce(0, (sum, o) -> sum + o.qty(), Integer::sum); // T=Order, U=Integer
BigDecimal total = orders.stream().map(Order::total).reduce(BigDecimal.ZERO, BigDecimal::add);
Optional<Order> biggest = orders.stream().reduce((a, b) -> a.total().compareTo(b.total()) >= 0 ? a : b);
Key points to cover:
StringBuilder), use collect, not reduce. reduce with mutation breaks in parallel, and copying is O(n²).mapToInt(...).sum(), max(comparator)) for clarity and to avoid boxing.Short answer: Parallel streams can speed up large, CPU-bound, stateless work (roughly up to the core count), but they add overhead: splitting, task scheduling, combining, and thread coordination. So they're:
ArrayList, IntStream.range);LinkedList or I/O sources, ordered operations (sorted, limit, forEachOrdered), boxing-heavy pipelines, or when the common pool is already busy (in servers).Always benchmark with JMH on production-like hardware. The "NQ model" (N elements × Q cost per element > ~10,000) is a useful rule of thumb.
Short answer: Use sorted(Comparator), with composed comparators. The stream itself stays unchanged (it isn't sorted in place):
List<Employee> sorted = employees.stream()
.sorted(Comparator.comparing(Employee::department)
.thenComparing(Employee::salary, Comparator.reverseOrder())
.thenComparing(Employee::name))
.toList();
Key points to cover:
sorted() is a stateful, full barrier: it buffers everything.ORDER BY, with an index) instead.Short answer: No. Mutating shared state from a parallel stream (list.add, counter++, map.put on non-concurrent maps) causes data races: lost updates, corrupt collections, or ArrayIndexOutOfBoundsException inside ArrayList. Even a thread-safe container adds contention, and loses ordering. Use reductions and collectors, which are designed for parallel execution: each thread accumulates into its own container, and the containers are merged.
List<String> bad = new ArrayList<>();
names.parallelStream().map(String::trim).forEach(bad::add); // RACE: wrong size, or an exception
List<String> good = names.parallelStream().map(String::trim).toList();
Map<String, Long> counts = words.parallelStream().collect(Collectors.groupingByConcurrent(w -> w, Collectors.counting()));
Collectors.toMap() and groupingBy()?Short answer:
toMap(keyFn, valueFn) produces one value per key. It throws IllegalStateException on duplicate keys, unless you pass a merge function ((a, b) -> a). It also throws NullPointerException for null values (it uses HashMap.merge internally). Its overloads accept a map supplier (LinkedHashMap::new, TreeMap::new).groupingBy(classifier) produces Map<K, List<T>>: all the elements sharing a key are collected together. It accepts a downstream collector (counting(), summingInt, mapping, toSet, maxBy, teeing), and a map factory.Map<String, Customer> byEmail = customers.stream()
.collect(Collectors.toMap(Customer::email, c -> c, (first, dup) -> first)); // explicit duplicate policy
Map<String, Long> ordersPerCity = orders.stream()
.collect(Collectors.groupingBy(Order::city, TreeMap::new, Collectors.counting()));
Learn it in depth → Collectors and groupingBy
Short answer: Use Collector.of(supplier, accumulator, combiner, finisher, characteristics...), or implement the Collector interface. You define:
IDENTITY_FINISH);UNORDERED, CONCURRENT, IDENTITY_FINISH.// Collect into an immutable top-N list without sorting the whole stream
static <T> Collector<T, ?, List<T>> topN(int n, Comparator<? super T> cmp) {
return Collector.of(
() -> new PriorityQueue<T>(cmp), // a min-heap by cmp
(pq, t) -> { pq.offer(t); if (pq.size() > n) pq.poll(); },
(a, b) -> { b.forEach(t -> { a.offer(t); if (a.size() > n) a.poll(); }); return a; },
pq -> pq.stream().sorted(cmp.reversed()).toList());
}
List<Order> top5 = orders.stream().collect(topN(5, Comparator.comparing(Order::total)));
Key points to cover:
Collectors.teeing combines two collectors, without writing a custom one.Stream.gather) handle custom intermediate operations (windows, scans, deduplication by key).peek() for, and when should you avoid it?Short answer: peek(action) runs a side effect for each element as it flows past, returning the same stream. It's intended for debugging (logging the elements between stages). Avoid it for business logic:
count() on a sized source since Java 9 doesn't execute peek);For side effects, use forEach at the end, or a proper map that returns new values.
Short answer: Use flatMap, which maps each element to a stream, and concatenates those streams:
List<List<OrderLine>> nested = orders.stream().map(Order::lines).toList();
List<OrderLine> flat = nested.stream().flatMap(List::stream).toList();
// Directly from the parent objects, and for deeper nesting chain more flatMaps:
List<String> skus = orders.stream().flatMap(o -> o.lines().stream()).map(OrderLine::sku).distinct().toList();
// Java 16+: mapMulti avoids creating a Stream per element (useful for small or optional expansions)
List<String> skus2 = orders.stream().<String>mapMulti((o, sink) -> o.lines().forEach(l -> sink.accept(l.sku()))).toList();
Short answer: The simplest readable version sorts, which is O(n log n):
Optional<Integer> secondHighest = numbers.stream()
.distinct() // "second highest distinct value"; drop this if duplicates count
.sorted(Comparator.reverseOrder())
.skip(1)
.findFirst();
For large inputs, a single O(n) pass, without sorting, is better:
int[] top = numbers.stream().mapToInt(Integer::intValue).collect(
() -> new int[]{Integer.MIN_VALUE, Integer.MIN_VALUE},
(t, x) -> { if (x > t[0]) { t[1] = t[0]; t[0] = x; } else if (x < t[0] && x > t[1]) t[1] = x; },
(a, b) -> { for (int x : b) { if (x > a[0]) { a[1] = a[0]; a[0] = x; } else if (x < a[0] && x > a[1]) a[1] = x; } });
// top[1] is the answer (check it for MIN_VALUE, meaning "not found")
Clarify the edge cases: duplicates (is [5, 5, 3] answered with 5 or 3?), fewer than two elements (return Optional.empty()), and negative numbers.
parallelStream() with I/O-bound tasks?Short answer:
CompletableFuture defaults) starves.ManagedBlocker, which ordinary I/O libraries don't use.Instead: use a virtual-thread executor, or CompletableFuture with a dedicated bounded executor, or structured concurrency, and limit concurrency with a semaphore.
Short answer: Yes.
filter, distinct, limit, skip, takeWhile and dropWhile reduce the count;flatMap/mapMulti can expand or shrink it (each element becomes 0..n elements);map, peek and sorted preserve it.That's why a stream's SIZED characteristic can be lost after certain operations. It matters for optimisations: count() can skip evaluating the pipeline only while the size is known, and toArray pre-sizes the result.
Q: What's the difference between findFirst() and findAny()?
A: findFirst returns the first element in encounter order. It's deterministic, but costly in parallel. findAny may return any element (in practice, often the first one sequentially). It's cheaper in parallel, and fine when any match will do.
Q: How do you avoid boxing overhead in streams?
A: Use the primitive streams (IntStream, LongStream, DoubleStream) through mapToInt and friends, and their terminal operations (sum, average, summaryStatistics). Use boxed() only when an object stream is needed.
Q: What is Collectors.teeing (Java 12)?
A: It passes every element to two collectors, then merges their results with a function. For example: count and sum together, giving an average object in one pass.
Q: When should you not use streams? A: With complex control flow (early returns, checked exceptions, several accumulators), in performance-critical tight loops over primitives (sometimes a plain loop is clearer and faster), and when debugging readability suffers. Streams are a tool, not a style mandate.