Streams — The Honest Guide¶
Streams are the most abused feature of modern Java. Team-average code sprinkles .parallel() on everything, collects to a List only to stream it again, and hides mutation inside map(). This file gives you the mental model to write streams that senior reviewers approve, and the honest data on when streams are slower than a plain for-loop.
Pipeline Anatomy¶
Every stream is exactly three parts:
Source Intermediate ops (lazy) Terminal op (eager)
────── ───────────────────────── ───────────────────
list.stream() .filter(...).map(...).sorted() .toList()
Sources: Collection.stream(), Arrays.stream(arr), Stream.of(...), Stream.iterate(...), Files.lines(path), IntStream.range(...).
Intermediate ops (return another stream, do not consume it): filter, map, flatMap, sorted, distinct, limit, skip, peek, mapToInt.
Terminal ops (consume the stream, produce a result): toList, collect, reduce, forEach, count, findFirst, findAny, anyMatch, allMatch, noneMatch, min, max, sum.
Without a terminal op, nothing runs. This is critical to understand.
Laziness — Why This Matters¶
Intermediate operations are lazy: they build a description of the pipeline but do not execute until a terminal op pulls values.
Stream<Integer> s = Stream.of(1, 2, 3, 4, 5)
.peek(x -> System.out.println("filter: " + x))
.filter(x -> x % 2 == 0)
.peek(x -> System.out.println("map: " + x))
.map(x -> x * 10);
// Nothing has printed yet.
int first = s.findFirst().get();
// Output:
// filter: 1
// filter: 2
// map: 2
// Because findFirst() short-circuits after the first match.
Laziness enables three optimizations:
Short-circuiting —
findFirst,anyMatch,limitstop early.Fusion — the pipeline processes each element through all stages before moving to the next, reducing memory pressure.
Infinite streams —
Stream.iterateandStream.generateonly work because downstream ops (likelimit) can stop them.
A stream is single-use. After a terminal op, calling another op throws IllegalStateException: stream has already been operated upon or closed.
The Common Mistakes¶
Mistake 1: parallel() as premature optimization¶
// Looks fast. Is often slower.
list.parallelStream().map(this::process).toList();
DZone’s benchmark by Angelika Langer showed parallel streams up to 15× slower than sequential for boxed operations because of:
Boxing/unboxing overhead on primitives
Poor spliterator implementations for
LinkedListand streams from iteratorsCache-line ping-pong and false sharing
Contention on the shared
ForkJoinPool.commonPool()
Parallel streams can win when all of these are true:
The dataset is genuinely large (rule of thumb: > 10,000 elements, but measure)
The per-element work is CPU-bound and non-trivial (not
x -> x * 2)The pipeline is stateless (no
sorted, nodistincton unordered data)The source has a good spliterator:
ArrayList, arrays,IntStream.range— yes;LinkedList,Files.lines— noYou are not already inside another parallel stream (nested
parallelStream()shares the same common pool — catastrophic contention)
Rule: never call .parallel() without a JMH benchmark. In 90% of code you look at, the sequential version is faster or the same.
Mistake 2: Collecting to List only to stream again¶
// Wasteful — collects, then re-streams
List<User> filtered = users.stream()
.filter(User::isActive)
.toList();
long count = filtered.stream().count();
// Correct — one pipeline
long count = users.stream().filter(User::isActive).count();
Every toList() allocates. If the intermediate list is only consumed once, keep streaming.
Mistake 3: Side effects inside intermediate ops¶
List<String> errors = new ArrayList<>();
List<User> valid = users.stream()
.peek(u -> { if (!u.isValid()) errors.add(u.name()); }) // side effect in peek
.filter(User::isValid)
.toList();
peek is meant for debugging, not for accumulation. This is unsafe under parallelization (ArrayList is not thread-safe). Use Collectors.partitioningBy:
Map<Boolean, List<User>> partitioned =
users.stream().collect(Collectors.partitioningBy(User::isValid));
List<User> valid = partitioned.get(true);
List<User> invalid = partitioned.get(false);
Mistake 4: Streams for imperative loops¶
// Awkward — index + mutation
IntStream.range(0, users.size()).forEach(i -> {
if (users.get(i).age() < 18) users.set(i, null);
});
// The for-loop honestly says what this does
for (int i = 0; i < users.size(); i++) {
if (users.get(i).age() < 18) users.set(i, null);
}
Streams are for value pipelines. Indexed mutation, breakout on side conditions, and stateful loops are for for and while.
Collectors — The Deep Dive¶
Collectors is the accumulation vocabulary for streams. Learn these:
Basic collectors¶
List<String> list = stream.collect(Collectors.toList()); // legacy: modifiable ArrayList
List<String> list2 = stream.toList(); // Java 16+: unmodifiable
Set<String> set = stream.collect(Collectors.toSet());
String joined = stream.collect(Collectors.joining(", ", "[", "]"));
groupingBy¶
// Group by simple key
Map<Role, List<User>> byRole = users.stream()
.collect(Collectors.groupingBy(User::role));
// Group by key, count per group
Map<Role, Long> countByRole = users.stream()
.collect(Collectors.groupingBy(User::role, Collectors.counting()));
// Group by key, sum a field per group
Map<Department, BigDecimal> payrollByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.reducing(BigDecimal.ZERO, Employee::salary, BigDecimal::add)));
partitioningBy¶
A specialization of groupingBy with a Predicate — always exactly two keys, true and false.
Map<Boolean, List<User>> partition = users.stream()
.collect(Collectors.partitioningBy(u -> u.age() >= 18));
Marginally faster than groupingBy(pred::test) because the map is fixed-size.
toMap¶
Map<Long, User> byId = users.stream()
.collect(Collectors.toMap(User::id, Function.identity()));
// With merge function for duplicate keys
Map<String, Integer> wordCounts = words.stream()
.collect(Collectors.toMap(w -> w, w -> 1, Integer::sum));
Without a merge function, duplicate keys throw IllegalStateException. This is a common bug — always ask “could keys collide?”
teeing (Java 12+)¶
Two collectors run in parallel over the same stream, merged at the end:
record Stats(double average, long count) {}
Stats s = numbers.stream()
.collect(Collectors.teeing(
Collectors.averagingDouble(Double::doubleValue),
Collectors.counting(),
Stats::new));
Hugely useful when you want two summary values without traversing twice.
toList() vs Collectors.toList()¶
Form |
Since |
Return type |
|---|---|---|
|
Java 16 |
Unmodifiable list |
|
Java 8 |
Modifiable |
|
Java 10 |
Unmodifiable |
Default to stream.toList() in Java 16+ code. Use Collectors.toList() only if callers must mutate the returned list. Use Collectors.toUnmodifiableList() if you are on Java 10–15.
The flatMap Trap and Fix¶
flatMap flattens Stream<Stream<T>> (or Stream<Collection<T>>) into Stream<T>:
List<List<Integer>> nested = List.of(List.of(1,2), List.of(3,4), List.of(5));
List<Integer> flat = nested.stream()
.flatMap(List::stream)
.toList(); // [1, 2, 3, 4, 5]
Common mistake: using map when you meant flatMap:
// Wrong — gives Stream<Stream<Order>>
users.stream().map(u -> u.orders().stream()).toList();
// Right — gives Stream<Order>
users.stream().flatMap(u -> u.orders().stream()).toList();
Primitive Streams — When to Use¶
IntStream, LongStream, DoubleStream avoid boxing:
int sum = users.stream().mapToInt(User::age).sum();
OptionalDouble avg = users.stream().mapToInt(User::age).average();
Use them for numeric aggregation. Use .boxed() or .mapToObj(...) to go back to reference streams.
⚠️ What Most People Get Wrong¶
They treat streams as a style choice (“my team writes functional code”) rather than as what pipelines actually are. Streams shine for: filter→transform→aggregate over immutable data. They hurt when you need indexed access, mutation of the source, early-exit on complex conditions, or interaction with checked exceptions. The senior move is to pick the honest tool per situation, not to be consistent for consistency’s sake.
The second big miss: they do not know that Stream.parallel() submits to the common ForkJoinPool. That pool is shared with every parallel stream in your JVM, including inside libraries. A single misbehaving parallel stream can stall unrelated code. If you truly need parallelism, submit to a dedicated ForkJoinPool you control — or reach for virtual threads (Phase 05).
Debugging Streams¶
Streams are notoriously hard to debug because the pipeline is opaque. Techniques:
peekbetween stages — log each element passing through. Remove before commit.Split into named variables —
var filtered = users.stream().filter(...);thenvar mapped = filtered.map(...);. Verbose but debuggable.IntelliJ Stream Debugger — built-in visualization of what happens at each stage. Learn the shortcut.
Extract lambdas to named methods — you can breakpoint inside a
private static User enrich(User u) {...}in a way you cannot inside.map(u -> ...).
What to Practice This Week¶
Take 10 imperative
for-loop-plus-mutable-list patterns from your codebase. Convert to stream +toList(). Note the ones that read worse — keep those imperative.Write a
groupingBy(role, counting())and agroupingBy(role, reducing(...))from memory.Pick one existing
parallelStream()in code you have seen. JMH-benchmark it against sequential. Publish the result on your GitHub as a reference for your team.Read Bloch Effective Java Items 45–48: “Use streams judiciously,” “Prefer side-effect-free functions in streams,” “Prefer Collection to Stream as a return type,” “Use caution when making streams parallel.” Item 48 alone will change how you use
.parallel().
Return to README.md · Previous: 02_functional_interfaces_lambdas.md · Next: 04_optional_done_right.md