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:

  1. Short-circuitingfindFirst, anyMatch, limit stop early.

  2. Fusion — the pipeline processes each element through all stages before moving to the next, reducing memory pressure.

  3. Infinite streamsStream.iterate and Stream.generate only work because downstream ops (like limit) 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 LinkedList and streams from iterators

  • Cache-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, no distinct on unordered data)

  • The source has a good spliterator: ArrayList, arrays, IntStream.range — yes; LinkedList, Files.lines — no

  • You 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

stream.toList()

Java 16

Unmodifiable list

stream.collect(Collectors.toList())

Java 8

Modifiable ArrayList (implementation-specific)

stream.collect(Collectors.toUnmodifiableList())

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:

  1. peek between stages — log each element passing through. Remove before commit.

  2. Split into named variablesvar filtered = users.stream().filter(...); then var mapped = filtered.map(...);. Verbose but debuggable.

  3. IntelliJ Stream Debugger — built-in visualization of what happens at each stage. Learn the shortcut.

  4. 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

  1. 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.

  2. Write a groupingBy(role, counting()) and a groupingBy(role, reducing(...)) from memory.

  3. 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.

  4. 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