On which thread pool do supplyAsync and runAsync run by default, and how do you override it?
answer
- default = ForkJoinPool.commonPool()
- size = availableProcessors() - 1
- shared JVM-wide with parallel streams → starvation
- pass Executor in the second overload to isolate
- parallelism < 2 may run on the caller thread
basics
~20 sBy 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 sThe 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
Knows the default is the common ForkJoinPool and that you can pass an Executor to change it.
States the (cores−1) sizing and that it's shared JVM-wide, and chooses a custom pool for blocking work.
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.
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