Definition
- ArrayList is not thread-safe:
addmutates thesizefield 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?
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));
}
}
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:
- Both read the current
size(say 5). - Both write their element to
elementData[5]. - Both increment
sizeto 6. - 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.
// 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:
- Supplier creates a separate
ArrayListper thread. - Accumulator adds elements to the thread-local list (no sharing).
- 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?
// 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 offorEach(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?"
ArrayIndexOutOfBoundsExceptionduring an internal array resize race.
Visual Overview
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.
---