Explanation
What just happened
parallelStream() doesn’t create threads of its own. It splits the work over the JVM-wide ForkJoinPool.commonPool() and lets the calling thread (main) help. Each buildReport blocked its thread, so the whole pool was busy. Then an unrelated request called supplyAsync with no executor, which uses the same pool. Its one-unit task waited until a worker ran out of report work.
Try the what-if switch
its own executor: supplyAsync(task, ioPool) runs on ioPool’s thread, so it starts at once no matter how busy the common pool is.
Why it bites
The common pool is shared by everything in the JVM: parallel streams, CompletableFuture’s *Async methods without an executor, and many libraries. It’s sized for CPU work, with one thread fewer than you have cores. A few blocking calls (database, HTTP, files) can tie it all up, and code elsewhere slows down for no visible reason.
Rules of thumb
parallelStream()is for CPU-heavy work on large data, not for blocking calls.- Pass your own executor to
supplyAsync,thenApplyAsyncand friends whenever the work can block. - On JDK 21+, an executor of virtual threads is a good choice for blocking calls:
Executors.newVirtualThreadPerTaskExecutor().
How to spot it
Take a thread dump. If every ForkJoinPool.commonPool-worker thread is stuck in I/O, this is your problem. The thread dump reader checks for exactly this.
Common questions
Which threads does parallelStream use?
The JVM-wide ForkJoinPool.commonPool(), plus the calling thread, which helps with the work. It doesn't create threads of its own.
How many threads does the common pool have?
By default, one fewer than the number of available processors, so a 2-core container gets a single worker. With only one, CompletableFuture's async methods up to JDK 21 start a new thread per task instead. From JDK 25 they share that one worker.
How do I stop blocking work from starving the common pool?
Pass your own executor: supplyAsync(task, ioPool). Later *Async stages without an executor go back to the common pool, so pass it to each one. On JDK 21+, Executors.newVirtualThreadPerTaskExecutor() suits blocking calls.
Verified: this timeline matches a real program, checked in CI on JDK 21 and 25. How we verify. Unclear or wrong? Tell us