Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Advanced6 min readUpdated
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() - 1 and shared JVM-wide.
  • Sources split differently: arrays and ArrayList split perfectly, LinkedList and Stream.iterate split 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

how big is it?
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.
Everything shares the common pool. One long or blocking task in it delays every other user.

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.

Recursive halving. A source that cannot report its size, or cannot split evenly, breaks this tree.

Whether that works depends entirely on the source:

SourceSplitsWhy
int[], ArrayListExcellentRandom access, known size, exact halves
HashMap, HashSetGoodSplits by bucket range
TreeMapFairBalanced tree splits reasonably
LinkedListPoorMust walk to find the midpoint
Stream.iterateTerribleEach element depends on the previous — cannot split at all
BufferedReader.linesPoorSequential 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.

not worth it
// 1000 elements, trivial work. Sequential is faster.
list.parallelStream().map(String::toUpperCase).toList();
worth it
// 100,000 elements, real CPU work per element, splittable source.
Arrays.stream(images).parallel().map(this::resizeAndCompress).toList();
actively harmful
// 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

ordered operations do more work in parallel
// 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

a race, and a silent one
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

the widely used, undocumented trick
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?
ForkJoinPool.commonPool(), which is a single JVM-wide pool sized to the number of available processors minus one, plus the calling thread. Every parallel stream, every CompletableFuture without an explicit executor, and any library doing the same all share it. That sharing is the reason one slow parallel stream can degrade unrelated parts of the application.
Can I make a parallel stream use my own thread pool?
Only through a documented side effect: submitting the terminal operation to your own ForkJoinPool makes the work run there, because the fork-join framework uses the submitting pool. It works, it is widely used, and it is not part of the specification. It also does not help with blocking work, because a ForkJoinPool is designed for CPU-bound tasks that never block.
Is a parallel stream faster than a sequential one?
Sometimes, and less often than people expect. It needs enough elements, enough work per element, a source that splits evenly, and no shared mutable state or blocking. A rough threshold is that the total work — elements times cost per element — should exceed roughly 100 microseconds before the coordination overhead pays for itself. Below that, sequential wins. Always measure.

Related tutorials