Why Parallel Streams Fail with ArrayList in Java (And How to Fix It)
Switching from stream() to parallelStream() in Java seems like an effortless way to boost throughput. However, if your pipeline mutates shared state, you will quickly run into bizarre bugs: unpredictable counts, missing data, and intermittent runtime exceptions like ArrayIndexOutOfBoundsException.
If you've written code like this:
List<Integer> ids = new ArrayList<>();
orders.parallelStream()
.filter(o -> o.getAmount() > 100)
.forEach(o -> ids.add(o.getId())); // Danger: Shared mutable state!You are experiencing classic thread safety violations. Let’s break down why this happens, how collect() solves it under the hood, and how to decide if parallel streams are even worth using in your application.
1. What Exactly Goes Wrong with ArrayList?
java.util.ArrayList is not thread-safe. When multiple worker threads in the common ForkJoinPool execute ids.add(...) at the same time, two distinct race conditions occur:
Race Condition #1: Overwritten Elements (Data Loss)
Under the hood, adding an element to an ArrayList looks roughly like this:
elementData[size] = element;
size = size + 1;Neither the write nor the increment is atomic. If Thread A and Thread B read the same size value (say, index 100) concurrently, both threads will write their value into elementData[100]. One value overwrites the other, and size may only increment once or twice inconsistently. This explains why your collection ends up with 3,987 or 3,991 elements instead of the expected 4,000.
Race Condition #2: ArrayIndexOutOfBoundsException
Before adding an item, ArrayList checks whether it needs to expand its backing array:
if (size == elementData.length)
grow();Imagine the array has capacity for 1,024 elements and size is currently 1,023. Thread A checks if it needs to grow; since 1,023 < 1,024, it skips the grow() step. Before Thread A can insert its item, Thread B runs the same check, also skips grow(), and inserts into index 1,023. Thread A now attempts to write into index 1,024 without resizing, triggering an ArrayIndexOutOfBoundsException.
2. Why is collect() Thread-Safe?
Streams are designed around functional principles. Instead of pushing data into an external list (a side-effect), you should let the stream framework reduce the data into a container using collect() or Java 16+'s .toList():
List<Integer> ids = orders.parallelStream()
.filter(o -> o.getAmount() > 100)
.map(Order::getId)
.toList(); // Or .collect(Collectors.toList())Here is why this approach works seamlessly in parallel:
- Isolated Sub-Tasks: The parallel stream splits your source collection into smaller chunks using a
Spliterator. Each thread in the pool works on its own independent sub-list. - Thread-Safe Reduction: No two threads write to the same list simultaneously. Once the threads finish processing their chunks, the stream combines the sub-results in pairs using the collector's combiner function (e.g.,
leftList.addAll(rightList)). - Deterministic Order: Unless explicitly configured with
.unordered(),collect()respects the encounter order of the source collection, preserving predictable, repeatable results.
3. When Are Parallel Streams Actually Worth It?
Parallel streams introduce overhead: task splitting, thread dispatching, context switching, and merging results. In many cases, a sequential stream is actually faster than a parallel one.
Use the following heuristics to determine whether parallel streams make sense:
The N × Q Rule of Thumb
The Java documentation recommends the N × Q heuristic, where:
- N is the number of data items.
- Q is the computational cost per item.
If N × Q > 10,000, parallelization often provides a tangible benefit. If you have 10,000 simple integers and your operation is a basic comparison (low Q), parallelization might just break even or perform worse. However, if each operation involves CPU-intensive math, parsing, or encryption (high Q), parallel streams will provide noticeable gains.
Data Structure Splitting Costs
Not all data structures split evenly:
- Excellent:
ArrayList, arrays, andIntStream.range()have low splitting costs because sizes are known in advance and can be split in O(1) time. - Poor:
LinkedList,Stream.iterate(), and I/O sources cannot be split efficiently without traversing elements first (O(N) cost).
Avoid Blocking I/O in the Common ForkJoinPool
By default, parallel streams run on ForkJoinPool.commonPool(), which has a worker thread count tied to your machine's CPU cores. If your pipeline performs blocking network calls, file reads, or database queries, it will starve the shared pool and degrade performance across your entire application. Keep parallel streams strictly for CPU-bound tasks.
Summary
- Never mutate external, non-thread-safe collections like
ArrayListinside a stream'sforEach(). - Replace
forEach(ids::add)with.toList()or.collect(Collectors.toList())for thread-safe parallel reductions. - Only parallelize CPU-bound tasks with large datasets ($N \times Q > 10,000$) where splitting overhead is minimal.