🌊 Streams API · Intermediate

Side effects & peek in Java

Why lambdas in streams should be stateless; peek is for debugging.

🧩 The mysteryYou add peek(System.out::println) to see what's flowing, then call count(). The count is right… but peek printed nothing. Was it ever there?

Pure workers

Stream lambdas should be stateless and non-interfering: they compute a result from their input instead of changing outside variables, and they never modify the stream's source. Side effects make results depend on execution order.

Building a list by side effect

✗ Breaks in parallel
List<Integer> out = new ArrayList<>();
nums.parallelStream()
    .map(n -> n * 2)
    .forEach(out::add);

ArrayList isn't thread-safe; parallel forEach adds from many threads — lost elements or exceptions.

✓ Let the stream build it
List<Integer> out = nums.parallelStream()
    .map(n -> n * 2)
    .toList();

No shared state: correct sequentially and in parallel.

peek: a window onto the belt

peek(action) lets you watch each element as it passes a point in the pipeline, without changing it. Its Javadoc says it exists mainly to support debugging.

Stream.of(1, 2)
    .peek(n -> System.out.print("p" + n))
    .map(n -> n * 10)
    .toList();  // prints p1p2
🔮 Predict it

Peeking before map

What does this print?

List<String> r = Stream.of("a", "b")
    .peek(s -> System.out.print(s + " "))
    .map(String::toUpperCase)
    .toList();
System.out.println(r);
  1. A B [A, B]
  2. a b [A, B]
  3. [A, B] a b
Show the answer

a b [A, B] — toList pulls both elements through, so peek sees them before map uppercases them. Then the list prints.

⚠️ The trap

peek may never run

Since Java 9, count() can compute the size directly when the source size is known and nothing changes it — skipping the pipeline, peek included. This prints only 3. Never put real work in peek.

long n = List.of("a", "b", "c").stream()
    .peek(System.out::println) // skipped!
    .count();
System.out.println(n); // 3
🤔 Think first

Why stateless?

A lambda increments a shared counter for each element of a parallel stream. Why might the total be wrong — and different every run?

Think about it, then reveal the answer

Threads process elements in an unpredictable order and update the counter at the same time, so updates collide (race conditions). Results that depend on shared state become wrong or random in parallel.

💼 In the real world

Debugging without regret

Developers drop peek(e -> log.debug(...)) into pipelines to trace data — fine while debugging. Production bugs appear when business logic (saving, sending emails) lives in peek or forEach side effects and silently stops running after a refactor or a JDK upgrade.

Key takeaways

  1. Prefer collect/toList over adding to a list in forEach
  2. Never modify the stream's source during the pipeline
  3. peek is for debugging, not for business logic
  4. Since Java 9, count() may skip the pipeline (and peek)
🤯 Did you know?

The "count() may skip the pipeline" optimisation arrived in Java 9 and surprised so many people that the count() Javadoc now warns about it explicitly.

Practice questions

What does this print?

List<Integer> r = Stream.of(1, 2)
    .peek(n -> System.out.print("p" + n + " "))
    .map(n -> n * 10)
    .toList();
System.out.println(r);
  1. [10, 20]
  2. p1 p2 [10, 20]
  3. [10, 20] p1 p2
  4. p10 p20 [10, 20]
Check your answer

p1 p2 [10, 20]. toList pulls both elements through, so peek prints p1 and p2 (with the values before map). Then the list is printed.

Run in parallel, this sometimes loses elements or throws. What's the best fix?

List<Integer> out = new ArrayList<>();
nums.parallelStream()
    .map(n -> n * 2)
    .forEach(out::add);
  1. Replace forEach(out::add) with .toList()
  2. Declare out as final
  3. Use peek(out::add) instead of forEach
  4. Call .sequential() after forEach
Check your answer

Replace forEach(out::add) with .toList(). ArrayList isn't thread-safe, and parallel forEach adds from many threads at once. Let the stream build the result itself with toList() (or collect).

Next: parallel() — one word that splits work across all your CPU cores. Free speed, or a trap?