Parallel Streams and the Common ForkJoinPool
Why every parallel stream in your JVM shares one pool, which sources split well, the N times Q rule for deciding, and why a blocking call inside a parallel stream can stall the whole application.
On this page
.parallel() is eight characters that can make code four times faster, four times slower, or
occasionally break an unrelated part of the application. The deciding factors are entirely
predictable once you know what is underneath.
Key Takeaways
- Every parallel stream uses the common ForkJoinPool, sized
availableProcessors() - 1and shared JVM-wide. - Sources split differently: arrays and
ArrayListsplit perfectly,LinkedListandStream.iteratesplit terribly. - The rough rule: N × Q — elements times cost per element — must be large, roughly 10,000+ simple operations.
- A blocking call inside a parallel stream occupies a common-pool thread and starves everything else using it.
- Ordered operations (
findFirst,limit,sorted) cost more in parallel than unordered ones.
One pool for everything
ForkJoinPool.commonPool().getParallelism(); // typically cores - 1
Runtime.getRuntime().availableProcessors(); // e.g. 8 -> parallelism 7
// The calling thread also participates, so an 8-core machine
// executes with 8 threads in total.This is the fact that matters operationally. In a web application, two concurrent requests both
calling .parallel() do not get a pool each — they compete for the same handful of threads. The
degradation is non-obvious: the symptom is unrelated latency, not a stack trace.
The pool size is settable at startup with
-Djava.util.concurrent.ForkJoinPool.common.parallelism=N, which is a global decision and rarely the
right lever.
How the work is split
Fork-join works by recursive splitting: the Spliterator halves itself until the chunks are small
enough, each chunk is processed by a worker, and results are combined back up the tree.
Whether that works depends entirely on the source:
| Source | Splits | Why |
|---|---|---|
int[], ArrayList | Excellent | Random access, known size, exact halves |
HashMap, HashSet | Good | Splits by bucket range |
TreeMap | Fair | Balanced tree splits reasonably |
LinkedList | Poor | Must walk to find the midpoint |
Stream.iterate | Terrible | Each element depends on the previous — cannot split at all |
BufferedReader.lines | Poor | Sequential source, unknown size |
Stream.iterate(0, i -> i + 1).limit(n).parallel() is the classic trap: it looks parallelisable and
is fundamentally sequential, so it is slower in parallel than sequentially. IntStream.range(0, n)
describes the same numbers and splits perfectly.
When it pays
The heuristic from the JDK authors is N × Q, where N is the number of elements and Q is the cost per element. The product needs to be large enough that the split-and-merge overhead — roughly tens of microseconds — disappears against it.
// 1000 elements, trivial work. Sequential is faster.
list.parallelStream().map(String::toUpperCase).toList();// 100,000 elements, real CPU work per element, splittable source.
Arrays.stream(images).parallel().map(this::resizeAndCompress).toList();// Blocking I/O inside the common pool: 200 threads' worth of work
// crammed into 7, and every other common-pool user waits behind it.
urls.parallelStream().map(httpClient::getBlocking).toList();That third case is the one to be able to explain. A ForkJoinPool is sized for CPU-bound work — one
thread per core, because more would only add context switching. Blocking calls violate that
assumption: the thread is neither computing nor available. Fork-join has a partial answer
(ManagedBlocker, which lets the pool compensate by starting another thread), but the practical
answer for I/O is a dedicated executor, CompletableFuture with your own pool, or virtual threads.
Ordering costs
// findFirst must respect encounter order: later chunks that finish early
// still have to wait for earlier ones.
orders.parallelStream().filter(pred).findFirst();
// findAny returns whichever chunk finds a match first.
orders.parallelStream().filter(pred).findAny();
// If order genuinely does not matter, say so — this removes coordination.
orders.parallelStream().unordered().distinct().toList();sorted(), distinct(), limit() and skip() all have to preserve encounter order on an ordered
stream, which requires buffering and coordination between chunks. On an unordered source such as a
HashSet, that cost is already gone.
forEach does not guarantee order in parallel; forEachOrdered does, at the cost of serialising the
terminal step.
Shared mutable state
List<String> results = new ArrayList<>();
orders.parallelStream().forEach(o -> results.add(o.customer()));
// ArrayList is not thread-safe. Outcomes: lost elements,
// nulls in the middle, or ArrayIndexOutOfBoundsException from a
// concurrent resize. Sometimes it just works, which is worse.
// Correct:
List<String> results = orders.parallelStream().map(Order::customer).toList();The collector framework handles this properly — each worker accumulates into its own container and
the combiner merges them. That is exactly why collect exists as a separate operation from reduce,
and why a Collector needs a combiner function.
Also beware of Collectors.toMap with a merge function that is not associative, and of any
comparator or mapper that reads mutable state — both produce results that vary run to run.
Using your own pool
ForkJoinPool custom = new ForkJoinPool(4);
try {
List<Result> out = custom.submit(
() -> items.parallelStream().map(this::compute).toList()
).get();
} finally {
custom.shutdown();
}Submitting the terminal operation to your own pool makes the stream execute there, because fork-join tasks run in the pool that submitted them. It works on every JDK to date and is not specified behaviour. Use it to isolate a known-heavy CPU workload from the common pool — not to make blocking I/O acceptable.
The answer to give
"Parallel streams use the shared common ForkJoinPool, sized to cores minus one. They pay off when
there are enough elements, enough work per element, and a source that splits evenly — arrays and
ArrayList do, LinkedList and Stream.iterate do not. The failure case I watch for is blocking
I/O inside a parallel stream, because it occupies common-pool threads and slows down everything else
in the JVM that uses them."
Then, if pushed on how you would decide: measure with JMH, and default to sequential. Most stream code in a web application is already parallel at the request level, and adding a second layer of parallelism inside each request usually makes throughput worse, not better.
Frequently Asked Questions
Which thread pool does parallelStream use?
Can I make a parallel stream use my own thread pool?
Is a parallel stream faster than a sequential one?
Related tutorials
- Collectors, groupingBy and Downstream CollectorsThe collector API in depth: multi-level groupingBy, downstream collectors, the toMap duplicate-key exception, the null-value trap, teeing and flatMapping, and writing a Collector by hand.
- Optional: Correct Use and Common AbuseWhat Optional was designed for and what it was not, the orElse versus orElseGet trap that evaluates the fallback every time, chaining with map and flatMap, and why Optional fields are a mistake.
- Stream API Fundamentals: Lazy PipelinesHow a stream pipeline actually executes: why nothing runs until the terminal operation, what short-circuiting really means, stateful versus stateless operations, and why a stream is single-use.
- Default & Static Methods in InterfacesWhy default methods were added, the three resolution rules when a class inherits conflicting defaults, calling a specific supertype with X.super.method(), and private interface methods.