skip to content

On which thread pool do supplyAsync and runAsync run by default, and how do you override it?

level: middleimportance: must knowfreq 65%

answer

  1. default = ForkJoinPool.commonPool()
  2. size = availableProcessors() - 1
  3. shared JVM-wide with parallel streams → starvation
  4. pass Executor in the second overload to isolate
  5. parallelism < 2 may run on the caller thread

basics

~20 s

By default they run on the shared ForkJoinPool.commonPool(). Each async method also has an overload that takes an Executor, so you pass your own thread pool as a second argument to control where the task runs.

solid answer

~40 s

The no-executor overloads (supplyAsync(supplier), runAsync(runnable), and the async chaining steps) submit work to ForkJoinPool.commonPool()—a single JVM-wide pool sized by default to availableProcessors() minus one. That's fine for short CPU-bound tasks, but it's shared with parallel streams and other CompletableFutures, so blocking work (network, JDBC, file I/O, Thread.sleep) can exhaust its few threads and stall unrelated code. There's a subtle edge: if the common pool has parallelism of 1 (e.g. a single-core box), the JVM may run the task on the *caller's* thread instead. To avoid all this, use the second overload—supplyAsync(supplier, executor)—and pass a dedicated Executor (often a fixed or cached thread pool, or a virtual-thread executor) sized for your workload. Naming the pool's threads also makes blocking tasks easy to spot in thread dumps.

go deeper

for a junior

Knows the default is the common ForkJoinPool and that you can pass an Executor to change it.

for a middle

States the (cores−1) sizing and that it's shared JVM-wide, and chooses a custom pool for blocking work.

for a senior

Explains starvation risk, the caller-thread edge case at low parallelism, and sizing/naming a dedicated executor; reaches for virtual threads for blocking I/O.

for a principal

Sets organization-wide conventions for executor injection, bulkheading pools per dependency, observability (named threads, metrics), and capacity planning across services.

## The problem this addresses When you write `CompletableFuture.supplyAsync(() -> work())`, *something* has to decide which thread actually runs `work()`. That decision is made by an **Executor**—an object with one job: accept a task and run it on some thread. The question is which executor CompletableFuture uses when you don't say. ## The default: the common ForkJoinPool `ForkJoinPool` is a work-stealing thread pool. `ForkJoinPool.commonPool()` is a **single, static, JVM-wide instance** that the platform shares. By default its *parallelism* (target number of worker threads) is: ``` Runtime.getRuntime().availableProcessors() - 1 ``` So on an 8-core machine it targets 7 workers. The async methods of `CompletableFuture` that don't take an executor—`supplyAsync(s)`, `runAsync(r)`, `thenApplyAsync(f)`, etc.—all submit to this common pool. Why a shared pool of roughly core-count threads? Because the design assumes tasks are **short and CPU-bound**: you want about one busy thread per core and no more, to avoid context-switching overhead. ## Why the default is dangerous for blocking work The common pool is shared by: - every `CompletableFuture` async step that doesn't name an executor, - parallel streams (`stream().parallel()`), - any other library that uses the common pool. It has only ~(cores−1) threads. If you submit a handful of tasks that **block**—waiting on a socket, a database, a lock, or `Thread.sleep`—those few threads sit idle-but-occupied, and *every other* user of the common pool across the whole JVM is starved. This is a classic production incident: a slow downstream call freezes seemingly unrelated parallel-stream code. ## The caller-thread edge case If the common pool's parallelism is **less than 2** (e.g. a one- or two-vCPU container), the JVM cannot rely on the pool and instead runs each async task on a fresh thread *or even synchronously on the calling thread*. So "async" is not guaranteed to be off-thread on tiny machines. Don't depend on the common pool for true off-thread execution on constrained hardware. ## The override: pass your own Executor Every async creator and chaining method has a paired overload whose last parameter is an `Executor`: ```java ExecutorService io = Executors.newFixedThreadPool(50, namedFactory("io")); CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> callDatabase(), io); ``` Now `callDatabase()` runs on *your* pool, isolated from the common pool. Guidelines: - **CPU-bound, short**: the common pool is acceptable. - **Blocking I/O**: use a dedicated pool sized for the concurrency you need (blocking threads are cheap to *have*, so a larger pool is fine), or a **virtual-thread executor** (`Executors.newVirtualThreadPerTaskExecutor()`, Java 21+) which scales blocking work cheaply. - **Name the threads** via a custom `ThreadFactory` so thread dumps and profilers attribute work correctly. ## Mental model - No executor argument → shared common ForkJoinPool (small, CPU-bound assumption, JVM-wide). - Executor argument → your pool, your sizing, your isolation. The rule of thumb: **never run blocking work on the common pool.**

  • Why is the common pool a poor choice for blocking I/O tasks?
    It has only ~(cores−1) threads and is shared JVM-wide. Blocking tasks occupy those few threads, starving parallel streams and other CompletableFutures. Use a dedicated/virtual-thread executor instead.
  • How many threads does the common ForkJoinPool target by default?
    availableProcessors() minus one. It can be tuned via the java.util.concurrent.ForkJoinPool.common.parallelism system property.

saying these in an interview costs you the question

  • Claiming each supplyAsync creates its own new thread
  • Saying the common pool has unlimited or fixed-100 threads
  • Running blocking JDBC/HTTP on the common pool and assuming it scales
  • Assuming async always runs off the calling thread regardless of core count

context