When Parallel Streams Become a Production Problem
parallelStream() is one call away and looks like free concurrency. On I/O-bound work sharing a JVM-wide pool, it is a way to make unrelated code slow.
list.parallelStream() is the shortest distance between sequential code and a
concurrency incident. It reads like an optimisation, it type-checks, and in a
benchmark of one thing at a time it is often genuinely faster. Then it goes to
production alongside other code and something unrelated gets slow.
The part that surprises people
By default, parallel streams run on the common ForkJoinPool, which is shared by the entire JVM. Its size defaults to one less than your available processors.
So a parallel stream is not “some threads for my work”. It is the pool — the same one every other parallel stream in the process is using, and the same one some library you did not write may be using.
Why I/O makes it worse
Fork/join is designed for CPU-bound, divide-and-conquer work: short tasks that keep a core busy and finish. Blocking I/O breaks that model. A thread waiting on a database round-trip is a pool thread that is not available and not doing anything either.
Saturate the common pool with blocking work and you have not parallelised your batch. You have taken the JVM’s shared concurrency budget and spent it on waiting.
// looks like concurrency, behaves like a global lock under load
records.parallelStream()
.forEach(r -> repository.enrich(r)); // blocking call
What to do instead
Own your pool. For I/O-bound work, use an executor you sized for that work, so its limits are explicit and its saturation is yours alone.
var pool = Executors.newFixedThreadPool(WORKERS);
try {
var futures = partitions.stream()
.map(p -> CompletableFuture.runAsync(() -> process(p), pool))
.toList();
CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();
} finally {
pool.shutdown();
}
Or use virtual threads, which is what they are for. Blocking a virtual thread is cheap, so the whole objection above evaporates — but note that this makes the database your limit, so you still need a bound somewhere.
Batch before you parallelise. Most of the time the win was never
concurrency. enrich(records) as one call beats enrich(record) on N threads,
because it removes round-trips instead of overlapping them. Reach for the
batching first; it is the cheaper and more durable fix.
The rule I actually apply
Parallel streams for CPU-bound work over an in-memory collection, where the work is short and there is no I/O in the lambda. Anything that touches a network, a disk or a database gets an explicit executor with an explicit size.
The test is not “is this faster in isolation” but “what does this do to everything else when it is under load”.