Back to Parallel Streams
Q8Question 8 of 10

Demonstrate a bug caused by a shared mutable ArrayList inside forEach of a parallel stream

Key Concepts
  • Parallel forEach with list::add is a data race because ArrayList is not thread-safe
  • Multiple threads call ArrayList.add concurrently, mutating size and the internal array without synchronization
  • Symptoms are non-deterministic: result list shorter than expected, contains nulls, or throws ArrayIndexOutOfBoundsException
  • The bug is intermittent: it may pass in tests with small data and fail in production under load
  • The fix is not to synchronize the list but to stop sharing mutable state entirely
  • Use collect(Collectors.toList()) which manages per-thread accumulators merged safely

Definition

Parallel `forEach` adding to a shared `ArrayList` is a data race: results are corrupt, contain nulls, or throw `ArrayIndexOutOfBoundsException`.
  • ArrayList is not thread-safe: add mutates the size field and the internal element array without locking.
  • Concurrent adds interleave badly: multiple threads writing simultaneously cause lost updates, null entries, or resize races.
  • Symptoms are non-deterministic: the result list is shorter than expected, contains nulls, or throws ArrayIndexOutOfBoundsException.
  • Intermittent failure makes this dangerous: it may pass in tests and fail in production.
  • Root cause is sharing mutable state across threads with no synchronization.

At the National Bank of India, several clerks write transactions into the same ledger at once without taking turns. They overwrite each other's lines, leave blanks, or tear pages. The ledger ends up wrong, and the error is different every time.

Understanding the Concepts

What is the bug?

The bug is calling list.parallelStream().forEach(results::add) where results is a shared ArrayList. Multiple threads call ArrayList.add simultaneously, but ArrayList is not thread-safe. The add method mutates an internal size field and an element array without any synchronization, so concurrent calls interleave and corrupt the list.

How do you demonstrate it?

JAVA
package com.bank.hr;

import java.math.BigDecimal;
import java.util.*;
import java.util.stream.IntStream;

public class ParallelRaceCondition {

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

        // WRONG: shared mutable ArrayList with parallel forEach
        List<String> results = new ArrayList<>();
        accountIds.parallelStream().forEach(results::add);

        System.out.println("Expected: " + accountIds.size());
        System.out.println("Actual:   " + results.size());
        System.out.println("Has nulls: " + results.contains(null));
    }
}
TEXT
Expected: 10000
Actual:   9847
Has nulls: true

The actual count varies on every run. Sometimes it throws ArrayIndexOutOfBoundsException instead.

Why does this happen internally?

When two threads call ArrayList.add at the same time:

  1. Both read the current size (say 5).
  2. Both write their element to elementData[5].
  3. Both increment size to 6.
  4. Result: one element is lost, and the list has a null gap or a corrupted index.

During a resize, the race is worse: one thread copies the array while another writes to the old array, causing ArrayIndexOutOfBoundsException.

What are common mistakes?

Mistake: Trying to fix it by synchronizing the list.

JAVA
// WRONG approach: wrapping in synchronized list
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
accountIds.parallelStream().forEach(syncList::add);
// Works but defeats the purpose of parallelism (serializes all adds)

// CORRECT approach: use collect
List<String> correct = accountIds.parallelStream()
    .collect(java.util.stream.Collectors.toList());
// Framework manages per-thread accumulators, no shared state

How does collect solve the problem?

collect(Collectors.toList()) uses the collector's supplier-accumulator-combiner contract. In parallel:

  1. Supplier creates a separate ArrayList per thread.
  2. Accumulator adds elements to the thread-local list (no sharing).
  3. Combiner merges the per-thread lists at the end.

No thread ever touches shared mutable state, so there is no race.

How is forEach different from collect in parallel?

| Aspect | forEach(list::add) | collect(toList()) |
|---|---|---|
| Thread safety | Not safe: shared mutable state | Safe: per-thread accumulators |
| Result | Corrupt, non-deterministic | Correct, deterministic content |
| Approach | Side-effect (mutates external state) | Functional (framework manages state) |
| Fix needed | Requires synchronization | None: designed for parallel |

What about using CopyOnWriteArrayList?

JAVA
// Thread-safe but extremely slow for frequent writes
List<String> cowList = new java.util.concurrent.CopyOnWriteArrayList<>();
accountIds.parallelStream().forEach(cowList::add);
// Each add copies the entire array: O(n) per add, O(n^2) total

This is correct but defeats the purpose: collect is both correct and efficient.

What should a senior engineer know?

The correct instinct is never to synchronize a plain list for parallel stream use. Instead, stop sharing mutable state entirely and use collect, which the stream framework manages safely. Side-effecting into external collections from streams, especially parallel ones, violates the side-effect-free guideline. Naming the non-determinism as a data race on shared mutable state is the insight an interviewer listens for.

What are common interview follow-up questions?

  • "How do you fix it?" Use collect(Collectors.toList()) instead of forEach(list::add).
  • "Would a synchronized list work?" Yes for correctness, but it serializes all adds and kills parallel performance.
  • "Why is the bug intermittent?" The race window depends on timing, data size, and JVM thread scheduling.
  • "What exception can it throw?" ArrayIndexOutOfBoundsException during an internal array resize race.

Visual Overview

DIAGRAM
  parallelStream().forEach(results::add)    ← WRONG

  Thread-1: add("SAV1")  ─┐
  Thread-2: add("SAV2")  ─┤── both read size=5
  Thread-3: add("SAV3")  ─┘   both write to index 5
                              one element lost, null gap
  ┌─────────────────────────────────────┐
  │  results: [..., "SAV1", null, ...]  │
  │  size: 9847 (expected 10000)        │
  └─────────────────────────────────────┘

  collect(Collectors.toList())              ← CORRECT

  Thread-1: local list → [SAV1, SAV4, ...]
  Thread-2: local list → [SAV2, SAV5, ...]
  Thread-3: local list → [SAV3, SAV6, ...]
       │
       │  combiner merges safely
       ▼
  [SAV1, SAV2, SAV3, SAV4, SAV5, SAV6, ...]  ← complete

Key Takeaway

The bug is adding to a shared, non-thread-safe ArrayList from forEach on a parallel stream. ArrayList.add mutates its size field and its internal element array without any locking, so when many parallel worker threads call add at the same time their writes interleave and corrupt the list. The symptoms are non-deterministic: the result is shorter than expected, contains nulls, or throws ArrayIndexOutOfBoundsException.

What makes this dangerous is that it is intermittent. With a small dataset in a unit test it often passes because the race window is tiny, and then it fails in production under load. At the National Bank of India, this exact pattern produced a transaction list that was silently short on some batch runs.

The correct fix is not to synchronize the add or wrap the list. It is to stop sharing mutable state entirely and use collect(Collectors.toList()), which the stream framework manages safely with per-thread accumulators merged at the end. Side-effecting into an external collection from a parallel stream violates the side-effect-free principle precisely because it invites this data race.

Remember one sentence: never mutate a shared collection from a parallel forEach because the race is silent and intermittent; use collect instead.

---

Share
Use ← → arrow keys to navigate