Back to Parallel Streams
Q9Question 9 of 10

Fix the parallel-stream collection bug using collect(Collectors.toList())

Key Concepts
  • collect(toList()) lets the framework manage thread-safe accumulation with per-thread containers
  • Each thread collects into its own ArrayList via the supplier, then the combiner merges them
  • No shared mutable state means no race condition, even in parallel
  • The result is correct and deterministic in content regardless of thread count
  • toList() preserves encounter order for ordered streams, even in parallel
  • collect is the single correct answer to parallel result gathering: never hand-roll parallel accumulation

Definition

Use `collect(toList())` so the framework handles thread-safe accumulation with per-thread containers merged at the end.
  • collect(Collectors.toList()) replaces the shared-mutable forEach(list::add) pattern.
  • Per-thread containers: each thread gets its own ArrayList via the collector's supplier.
  • Combiner merges the per-thread lists at the end, eliminating shared mutable state.
  • No race condition: no thread ever touches another thread's container.
  • Encounter order is preserved for ordered sources like ArrayList, even in parallel.

Instead of all clerks at National Bank of India writing into one ledger at once, each clerk keeps their own page and a supervisor staples the pages together at the end. No collisions, no lost entries.

Understanding the Concepts

What is the fix?

Replace parallelStream().forEach(list::add) with parallelStream().collect(Collectors.toList()). The collector manages result accumulation internally using the supplier-accumulator-combiner contract, which is designed for parallel safety.

How does the fix look in code?

JAVA
package com.bank.hr;

import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class ParallelCollectFix {

    public static void main(String[] args) {
        List<String> accountIds = IntStream.rangeClosed(1, 10_000)
            .mapToObj(i -> "SAV" + i)
            .toList();

        // WRONG: shared mutable state
        List<String> buggy = new ArrayList<>();
        accountIds.parallelStream().forEach(buggy::add);
        System.out.println("Buggy size: " + buggy.size());  // varies each run

        // CORRECT: collect manages accumulation
        List<String> correct = accountIds.parallelStream()
            .collect(Collectors.toList());
        System.out.println("Correct size: " + correct.size());  // always 10000

        // Also correct: Java 16+ toList()
        List<String> unmodifiable = accountIds.parallelStream().toList();
        System.out.println("Unmodifiable size: " + unmodifiable.size());
    }
}
TEXT
Buggy size: 9847
Correct size: 10000
Unmodifiable size: 10000

How does the collector work internally in parallel?

The collector has three components:

  1. Supplier (ArrayList::new): creates a new, empty list for each thread.
  2. Accumulator (List::add): adds elements to the thread-local list. No sharing.
  3. Combiner (List::addAll): merges two thread-local lists into one.
JAVA
// This is what Collectors.toList() does internally
Collector.of(
    ArrayList::new,        // supplier: one per thread
    List::add,             // accumulator: thread-local add
    (left, right) -> {     // combiner: merge two lists
        left.addAll(right);
        return left;
    }
);

How is Collectors.toList() different from Stream.toList()?

| Aspect | Collectors.toList() | Stream.toList() (Java 16+) |
|---|---|---|
| Result type | Mutable ArrayList | Unmodifiable list |
| Null elements | Allowed | Allowed |
| Modification | result.add(x) works | Throws UnsupportedOperationException |
| Parallel safety | Safe (collector contract) | Safe (collector contract) |

What are common mistakes?

Mistake: Using Collections.synchronizedList as the fix.

JAVA
// WRONG approach: technically safe but kills parallel performance
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
accountIds.parallelStream().forEach(syncList::add);
// Every add acquires a lock, serializing the parallel work

// CORRECT approach: let the collector handle it
List<String> correct = accountIds.parallelStream()
    .collect(Collectors.toList());
// Per-thread accumulators, no lock contention

Does collect preserve the order of elements?

For an ordered source (like ArrayList), Collectors.toList() preserves encounter order even in parallel. The combiner merges thread-local lists in the correct positional order, so the result matches what a sequential collect would produce.

For an unordered source or after calling unordered(), the order is not guaranteed, which can improve parallel performance.

What about collecting into other structures?

The same principle applies to any collector in parallel:

JAVA
// toSet() is also parallel-safe
Set<String> set = accountIds.parallelStream().collect(Collectors.toSet());

// toMap() is parallel-safe with a merge function
Map<String, Integer> map = accountIds.parallelStream()
    .collect(Collectors.toMap(id -> id, id -> id.length(), (a, b) -> a));

// groupingBy is parallel-safe
Map<Character, List<String>> grouped = accountIds.parallelStream()
    .collect(Collectors.groupingBy(id -> id.charAt(0)));

What should a senior engineer know?

The correct instinct is never to hand-roll parallel accumulation by synchronizing a plain collection. Always use collect with a collector, which embodies the side-effect-free principle: streams produce results through return values, not by mutating external state. The collector's combiner step is what makes parallel collection safe.

What are common interview follow-up questions?

  • "Why not use a synchronized list?" It works but serializes all adds, killing parallel performance.
  • "Does collect preserve order in parallel?" Yes, for ordered sources.
  • "What is the difference between toList() and Collectors.toList()?" Java 16's toList() returns an unmodifiable list; Collectors.toList() returns a mutable ArrayList.

Visual Overview

DIAGRAM
  collect(Collectors.toList()) in parallel:

  ┌─────────────────────────────────────────────┐
  │  Thread-1                                    │
  │    supplier → new ArrayList()                │
  │    accumulate: [SAV1, SAV4, SAV7, ...]       │
  ├─────────────────────────────────────────────┤
  │  Thread-2                                    │
  │    supplier → new ArrayList()                │
  │    accumulate: [SAV2, SAV5, SAV8, ...]       │
  ├─────────────────────────────────────────────┤
  │  Thread-3                                    │
  │    supplier → new ArrayList()                │
  │    accumulate: [SAV3, SAV6, SAV9, ...]       │
  └─────────────────────────────────────────────┘
       │
       │  combiner: addAll in encounter order
       ▼
  [SAV1, SAV2, SAV3, SAV4, SAV5, ..., SAV10000]
  ← complete, correct, deterministic

Key Takeaway

The fix for the parallel-stream race condition is to stop mutating a shared collection and instead use collect(Collectors.toList()). When you collect, the stream framework manages the accumulation: on a parallel stream it gives each worker thread its own intermediate container, fills them independently, and then merges them with the collector's combiner step. Because no thread ever touches shared mutable state, there is no race, and the result is correct on every run.

At the National Bank of India, this turned the intermittently-corrupt transaction list into one that always has precisely the expected count. The collect approach is both correct and efficient, unlike wrapping in Collections.synchronizedList which serializes all adds and kills parallel performance.

The principle is that streams should produce results through return values and collectors, not by mutating external state. This is exactly what collect was designed for: the supplier-accumulator-combiner contract makes parallel collection safe by construction.

Remember one sentence: collect(toList()) is the single correct answer to safe parallel result gathering because the framework manages per-thread accumulators merged at the end.

---

Share
Use ← → arrow keys to navigate