skip to content

Why is running blocking work on no-executor *Async stages (the ForkJoinPool common pool) risky, and what should you do instead?

level: seniorimportance: must knowfreq 62%

answer

  1. Common pool = one shared JVM-wide pool
  2. Parallelism ≈ cores − 1 (tiny, CPU-sized)
  3. Shared with parallel streams + all no-arg async
  4. Blocking it = starvation that cascades app-wide
  5. Fix: dedicated bounded executor (or virtual threads)

basics

~10 s

The no-executor *Async methods use one small shared ForkJoinPool for the whole JVM. Blocking it (I/O, waits) starves every other user of that pool. Pass your own executor sized for blocking work instead.

solid answer

~50 s

CompletableFuture's `*Async` methods without an explicit executor run on `ForkJoinPool.commonPool()`. That pool is shared JVM-wide — parallel streams, other libraries, and all your no-arg async stages compete for it — and it is sized for CPU-bound work, with parallelism defaulting to (CPU cores − 1). If you run blocking operations on it (network calls, JDBC, file I/O, latches), those threads sit idle-but-occupied, and because the pool is tiny you quickly exhaust it. The whole application's async work, including unrelated parts, stalls. The fix is to pass an explicit `Executor` to the `*Async` overload — a pool sized for your blocking workload (more threads than cores), ideally with a bounded queue and a sensible rejection policy. Keep the common pool for short, non-blocking, CPU-bound transforms only. On modern JDKs, virtual-thread executors are also a good fit for blocking stages.

code

java · 17 lines
java
// Risky: blocking JDBC on the shared common pool (no executor)
CompletableFuture.supplyAsync(this::loadId)
    .thenApplyAsync(id -> jdbc.query(id)); // runs on commonPool -> starvation risk

// Better: isolate blocking work on a dedicated bounded executor
ExecutorService dbPool = new ThreadPoolExecutor(
    8, 8, 60, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(100),
    new ThreadPoolExecutor.CallerRunsPolicy()); // backpressure

CompletableFuture.supplyAsync(this::loadId, dbPool)
    .thenApplyAsync(id -> jdbc.query(id), dbPool);

// Or, on JDK 21+, virtual threads for blocking I/O:
Executor vt = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture.supplyAsync(this::loadId, vt)
    .thenApplyAsync(id -> jdbc.query(id), vt);

go deeper

for a junior

Knows *Async 'uses a thread pool' and that blocking is bad, but may not know it's a single shared pool or how to supply a different executor.

for a middle

Explains that no-arg *Async uses the shared common pool sized to cores, and that blocking it is risky; knows to pass an executor for blocking work.

for a senior

Articulates pool starvation cascading across unrelated users, sizes a dedicated bounded executor with a rejection policy, and reserves the common pool for CPU-bound transforms.

for a principal

Establishes service-wide executor strategy: per-downstream bulkhead pools, backpressure/rejection policy, virtual-thread adoption for I/O, and guardrails preventing blocking calls on the common pool.

## What the common pool is `ForkJoinPool.commonPool()` is a **single, JVM-wide, lazily-initialized thread pool** that the JDK provides as a default place to run parallel work. Two big users share it: 1. **Parallel streams** (`stream().parallel()`), and 2. **CompletableFuture `*Async` methods called without an explicit executor.** Its default **parallelism** (number of worker threads) is `Runtime.getRuntime().availableProcessors() - 1`. On a 4-core box that is **3 threads**. It is deliberately small because it is designed for **CPU-bound** work: when work is CPU-bound, more threads than cores just cause contention, so matching threads to cores is optimal. ## Why blocking on it is dangerous A **blocking** operation is one where the thread waits for something external — a database query, an HTTP call, reading a file, `Thread.sleep`, acquiring a contended lock, awaiting a latch. While blocked, the thread is **occupied but doing no work**; it cannot run other tasks. Now combine that with a tiny shared pool: - Suppose the common pool has 3 threads and you submit 3 blocking JDBC calls via `thenApplyAsync`. All 3 threads are now parked waiting on the database. **Zero** threads remain. - Any other async stage, parallel stream, or library that also relies on the common pool is now **stuck behind your blocking tasks**. Throughput collapses; latency spikes. - This is **thread-pool starvation**: the pool's threads are all blocked, so the pool can make no progress, and the problem cascades to every unrelated user of the same pool. It is especially insidious because it is a **shared, hidden global resource**. A blocking call buried deep in one feature can degrade a completely different feature that happens to use parallel streams. The failure is non-local and hard to trace. > Note: ForkJoinPool has a `ManagedBlocker` mechanism that can temporarily spawn a compensation thread when a worker blocks, but CompletableFuture's plain blocking callbacks do **not** automatically use it, so you cannot rely on it to save you here. ## The fix: pass your own executor For any stage that **blocks**, use the `*Async(fn, executor)` overload and supply a **dedicated executor** sized for blocking work: - More threads than cores (blocking threads spend their time waiting, so you can afford many), e.g. a pool sized to your downstream's connection limit. - A **bounded** queue plus a deliberate **rejection policy** (e.g. caller-runs or fail-fast) so a backlog applies backpressure instead of running the JVM out of memory. - Often **one executor per kind of downstream** (DB pool, HTTP pool) so one slow dependency can't starve the others — the bulkhead pattern. Reserve the **common pool** (the no-arg `*Async`) for **short, non-blocking, CPU-bound** transforms — parsing, mapping, arithmetic — where its core-matched sizing is ideal. ## Virtual threads (modern JDKs) On JDK 21+, an executor backed by **virtual threads** (`Executors.newVirtualThreadPerTaskExecutor()`) is an excellent target for blocking stages: a blocked virtual thread cheaply parks and frees its carrier, so blocking no longer starves a small carrier pool. Passing such an executor to `*Async` sidesteps the whole problem for I/O-heavy pipelines. ## Summary rule **Non-blocking, CPU-bound → common pool is fine. Blocking → always pass your own (well-sized, bounded) executor, never the common pool.**

  • What is the default parallelism of the ForkJoinPool common pool?
    availableProcessors() - 1, so it can be as low as 1 on a 2-core machine. It is sized for CPU-bound work, not for many concurrent blocking tasks.
  • How do virtual threads change this calculus?
    A virtual-thread-per-task executor lets blocked tasks park cheaply and unmount their carrier, so blocking no longer starves a small pool — making it a good executor to pass to *Async for I/O stages.

saying these in an interview costs you the question

  • Saying 'just use the no-arg async, it's fine' for blocking I/O — that starves the shared common pool.
  • Thinking each CompletableFuture has its own threads — no-arg *Async all share one global pool.
  • Using an unbounded executor as the fix — that trades starvation for memory exhaustion under load; bound the queue.
  • Assuming ForkJoinPool's ManagedBlocker automatically rescues plain blocking callbacks — it does not for ordinary CompletableFuture stages.

context