skip to content

Where do parallel streams run their work by default, and why does the shared common ForkJoinPool matter?

level: middleimportance: must knowfreq 68%

answer

  1. default pool = common ForkJoinPool, JVM-wide & shared
  2. size = cores − 1 (caller thread also helps)
  3. blocking I/O in a stream starves the whole pool
  4. submit terminal op to your own ForkJoinPool to isolate
  5. ForkJoinPool.common.parallelism property resizes it globally

basics

~20 s

By default parallel streams run on a single thread pool shared by the whole JVM, called the common ForkJoinPool. Its size is about the number of CPU cores minus one. Because it is shared, a slow or blocking task can starve everything else using it.

solid answer

~50 s

Parallel streams submit their work to the common ForkJoinPool — one pool shared across the entire JVM. Its default parallelism is (available processors − 1) worker threads; the thread that triggered the terminal operation also participates, so effective parallelism roughly equals the core count. Because the pool is shared, every parallel stream, plus CompletableFuture and other ForkJoinPool users, competes for the same small set of threads. That makes two things dangerous: (1) running a blocking operation (I/O, locks, sleeps) inside a parallel stream ties up a common-pool thread and can stall unrelated work elsewhere in the app; (2) one huge parallel job can crowd out latency-sensitive ones. To isolate work, submit the stream's terminal operation as a task to your own ForkJoinPool — a stream started inside a pool's task runs on that pool instead of the common one. You can also resize the common pool via the java.util.concurrent.ForkJoinPool.common.parallelism system property, but that is global.

code

java · 14 lines
java
// Run a parallel stream on a private pool instead of the shared common pool
ForkJoinPool customPool = new ForkJoinPool(8);
try {
    long count = customPool.submit(() ->
        bigList.parallelStream()
               .filter(MyApp::expensiveCheck)
               .count()
    ).get();   // a parallel stream started inside a pool task uses THAT pool
} catch (InterruptedException | ExecutionException e) {
    Thread.currentThread().interrupt();
    throw new RuntimeException(e);
} finally {
    customPool.shutdown();
}

go deeper

for a junior

Knows parallel streams run on threads from a built-in pool rather than the main thread.

for a middle

Names the common ForkJoinPool, that it is shared JVM-wide, sized cores − 1, and that blocking in it is dangerous.

for a senior

Explains pool starvation and the noisy-neighbour problem, and demonstrates the submit-to-a-private-ForkJoinPool isolation trick and its caveats.

for a principal

Weighs parallel streams against other concurrency choices for the system — dedicated executors, reactive/async I/O, virtual threads — and treats the shared common pool as a global resource to govern.

## ForkJoinPool, briefly A **ForkJoinPool** is a thread pool designed for *divide-and-conquer* work: a task can *fork* (split itself into subtasks that run on other threads) and *join* (wait for and combine their results). It uses **work-stealing** — an idle worker thread steals queued subtasks from a busy worker — to keep all cores busy. Parallel streams are built on exactly this fork/join machinery: the source is recursively split into subtasks that fork, and the partial results join back together. ## The common pool Rather than spin up a fresh pool per parallel stream, the JVM provides **one shared pool**, `ForkJoinPool.commonPool()`, used by default for *all* parallel streams. Other JDK features also use it (for example `CompletableFuture`'s async methods when you do not pass an executor). Its default size — its *parallelism level* — is **the number of available processors minus one**. The reason it is *minus one*: the thread that calls the terminal operation does not just wait, it also joins in and does work, so one core is effectively contributed by the caller. Net effect: total concurrency ≈ core count. You can override the size globally with the system property `java.util.concurrent.ForkJoinPool.common.parallelism` (set at startup), but this affects every common-pool user. ## Why "shared" is a hazard Because there is *one* pool for the whole JVM, all parallel work contends for the same handful of threads. Two concrete problems: 1. **Blocking starves the pool.** The common pool is sized for CPU-bound work and has very few threads. If a stream lambda does something *blocking* — a network/database call, acquiring a contended lock, `Thread.sleep`, blocking I/O — that worker thread is parked and unavailable. A few such tasks can occupy *every* worker, and now unrelated parallel work across the whole application stalls. Parallel streams are for CPU-bound, non-blocking transforms; blocking work belongs on a dedicated, larger pool. 2. **Noisy-neighbour contention.** One large parallel computation can monopolise the pool and delay other latency-sensitive parallel tasks elsewhere in the same JVM. ## Escaping the common pool The standard trick: run the stream's *terminal operation* inside a task submitted to your **own** ForkJoinPool. When a parallel stream's terminal operation executes while running as a task of some ForkJoinPool, it uses *that* pool instead of the common one: ```java ForkJoinPool pool = new ForkJoinPool(8); try { long result = pool.submit(() -> bigList.parallelStream().filter(expensive).count() ).get(); // blocks until the pooled task finishes } finally { pool.shutdown(); } ``` This isolates the work (its own threads, its own size) so it cannot starve the common pool, and lets you size the pool for the workload. Note this is an undocumented-but-widely-relied-on behaviour of the implementation, and `get()` blocks the calling thread until done. ## Takeaways - Default execution = the JVM-wide common ForkJoinPool, sized cores − 1 (+ the caller). - Never block inside a common-pool parallel stream; it can stall the whole app. - Submit to a private ForkJoinPool to isolate and size the work.

  • Why is the common pool sized to cores minus one rather than cores?
    Because the thread that invokes the terminal operation also participates in the fork/join work, so it effectively contributes the missing thread; total concurrency ends up around the core count.
  • What goes wrong if you do a blocking database call inside a parallelStream() on the common pool?
    Each blocked lambda pins one of the pool's few worker threads. Enough of them can occupy every worker, starving all other common-pool work (other parallel streams, CompletableFutures) across the whole JVM until the calls return.

saying these in an interview costs you the question

  • Thinking each parallel stream gets a fresh, isolated pool — they all share one common pool.
  • Doing blocking I/O or contended locking inside a parallel stream lambda on the common pool.
  • Believing the custom-pool trick changes the thread pool merely by calling parallel() — you must run the terminal operation inside a task of that pool.
  • Assuming the common pool grows under load — it has a small fixed parallelism level.

context