skip to content

What is the ForkJoinPool common pool, who shares it, and why does blocking work on it cause problems?

level: seniorimportance: must knowfreq 62%

answer

  1. commonPool() = one JVM-wide shared ForkJoinPool
  2. Parallelism = cores − 1; daemon threads
  3. Parallel streams + CompletableFuture default-async use it
  4. Blocking on it starves all other users → stalls/deadlock
  5. Fixes: own Executor, separate/virtual-thread pool, ManagedBlocker

basics

~20 s

The common pool is a single ForkJoinPool the JVM shares process-wide. Parallel streams and CompletableFuture's default async methods all run on it. It is sized to the number of CPU cores minus one, so if you run blocking tasks on it, you starve every other feature that also uses it.

solid answer

~50 s

ForkJoinPool.commonPool() is a lazily-initialised, JVM-wide shared pool. By default its parallelism is the number of available processors minus one (so the submitting thread makes up the difference), and its threads are daemons. Crucially, it is the default execution context for parallel streams and for CompletableFuture's async methods that don't take an explicit Executor. Because it is shared and small (cores-sized), it is built for short, CPU-bound, non-blocking tasks. If you submit work that blocks — I/O, locks, sleeps, or a nested parallel stream waiting on the same pool — those workers tie up the limited threads, and every other part of the application using the common pool (other parallel streams, other CompletableFutures) is starved and stalls. The fixes: pass your own dedicated Executor to CompletableFuture's *Async overloads, run blocking work on a separately sized pool (or virtual threads), and wrap unavoidable blocking inside a ForkJoinPool.ManagedBlocker so the pool can compensate by spawning a temporary thread.

go deeper

for a junior

Knows the common pool is a shared thread pool and that parallel streams use it; understands you shouldn't fill it with slow blocking work.

for a middle

Can name the common pool's default size (cores − 1) and that both parallel streams and default CompletableFuture async run on it, and knows to pass a custom Executor for blocking work.

for a senior

Explains the starvation/deadlock mechanism when blocking on the shared cores-sized pool and the concrete fixes (dedicated executor, separate sizing, ManagedBlocker).

for a principal

Reasons about cross-feature interference from a shared pool at service scale, sizing/tuning the common pool via system properties, and choosing virtual threads vs Fork/Join for blocking-heavy workloads.

## What the common pool is `ForkJoinPool.commonPool()` returns a **single, process-wide ForkJoinPool** that the JVM creates lazily on first use and never shuts down for you. "Common" = *shared by everyone in the JVM*. It exists so that code wanting parallelism doesn't each spin up its own pool (which would oversubscribe the CPU with far more threads than cores). Key defaults: - **Parallelism = available processors − 1.** On an 8-core machine the common pool has 7 worker threads. The "−1" assumes the thread that *submitted* the work also participates, so total ≈ core count. (On a single-core machine it falls back to running tasks in the caller.) - **Daemon threads.** Its workers are daemon threads, so they don't keep the JVM alive — the process can exit even if common-pool threads exist. - **Configurable** via system properties (e.g. `java.util.concurrent.ForkJoinPool.common.parallelism`). ## Who shares it (this is the trap) Two very common features run on the common pool *by default*, often without developers realising: 1. **Parallel streams.** `collection.parallelStream()` / `stream().parallel()` execute their pipeline on the common pool. There is no parameter to choose a pool — they always use the common pool (short of an undocumented trick of running the terminal op inside your own ForkJoinPool). 2. **CompletableFuture async methods without an Executor.** `CompletableFuture.supplyAsync(supplier)`, `thenApplyAsync(fn)`, etc. — the overloads that **don't** take an `Executor` — run on the common pool. So a single small pool is multiplexed across unrelated parts of your application. ## Why blocking on it is dangerous The pool is sized to **cores**, and Fork/Join assumes tasks are **short and CPU-bound**. A worker that **blocks** (waits on a network call, a lock, `Thread.sleep`, a database round-trip, or `future.get()` on work scheduled to the *same* pool) occupies one of those few threads while doing nothing useful. Block enough workers and the pool has no free threads: - Other parallel streams elsewhere in the app stall. - Other CompletableFuture stages queue up and don't run. - In the worst case you get a **deadlock**: a common-pool task waits for another task that can only run on the common pool, but every worker is already blocked waiting. Because the symptom appears in *unrelated* code that merely shares the pool, these bugs are hard to localise. ## The fixes 1. **Give CompletableFuture its own Executor.** Always prefer the `*Async(fn, executor)` overloads with a dedicated, appropriately-sized `Executor` for any blocking or long-running stage. Don't let blocking work land on the common pool. 2. **Don't run blocking work in parallel streams.** Parallel streams are for CPU-bound aggregation. For blocking fan-out, use an explicit executor (or virtual threads) instead. 3. **Size a separate pool for blocking work.** A pool tuned to your I/O concurrency (often many more threads than cores) — or **virtual threads** (Java 21+), which are cheap and designed to block — is the right tool for blocking workloads. 4. **ManagedBlocker for unavoidable blocking.** If you must block inside a ForkJoinPool, wrap it in **`ForkJoinPool.ManagedBlocker`** and call it via `ForkJoinPool.managedBlock(...)`. This tells the pool the worker is about to block, so it can **spawn a temporary compensation thread** to keep the configured parallelism (number of *running* workers) up while one is parked. It mitigates starvation but doesn't make blocking *cheap*. ## Mental model The common pool is a small shared kitchen with one cook per stove (≈ cores). Parallel streams and CompletableFutures all send orders to that same kitchen. If one order makes a cook stand and wait by the phone (blocking), that stove is dead — and since there are only as many stoves as cores, a few waiting cooks bring the whole kitchen to a halt for *everyone*.

  • How do you keep a blocking CompletableFuture stage off the common pool?
    Use the *Async overloads that take an Executor — e.g. supplyAsync(task, myExecutor) or thenApplyAsync(fn, myExecutor) — passing a dedicated, appropriately-sized pool (or a virtual-thread executor). The no-Executor overloads default to the common pool.
  • What does ForkJoinPool.ManagedBlocker do for you?
    It signals to the pool that a worker is about to block, letting the pool spawn a temporary compensation thread to maintain the target parallelism while that worker is parked. It prevents a blocked worker from silently shrinking the pool's effective concurrency, mitigating starvation.

saying these in an interview costs you the question

  • Thinking parallel streams use a fresh pool per stream — they share one common pool
  • Believing each CompletableFuture gets its own threads — the no-Executor overloads use the common pool
  • Running blocking I/O on a parallel stream or default-async CompletableFuture and expecting isolation
  • Assuming the common pool grows under load — it's cores-sized unless ManagedBlocker compensates
  • Calling shutdown() on the common pool (it's meant to live for the JVM and ignores shutdown)

context