skip to content

What do the *Async variants (thenApplyAsync, etc.) change, and why does supplying your own Executor matter?

level: seniorimportance: should knowfreq 60%

answer

  1. non-Async: runs on completing thread, or inline on caller if already done
  2. Async (no executor): ForkJoinPool.commonPool() — CPU-sized, shared
  3. Blocking work on common pool → starvation, stalls parallel streams
  4. Pass your own Executor for blocking I/O: isolation, sizing, naming
  5. Async on thenCompose controls the composing fn, not the inner future

basics

~20 s

The Async variants run the callback on a separate thread pool instead of whatever thread completed the previous stage. Without an Executor they use the shared common ForkJoinPool; passing your own pool keeps blocking work off that shared pool.

solid answer

~50 s

Non-Async transforms (thenApply, thenCompose, thenAccept) run the callback on the thread that completed the upstream stage — or synchronously on the caller's thread if the stage is already complete. That's cheap but unpredictable: a slow callback can hog the thread that completed the future, and behaviour depends on timing. The Async variants always hand the callback to an Executor. With no executor argument they use ForkJoinPool.commonPool(), which is sized to CPU cores and shared process-wide — fine for short CPU-bound steps, dangerous for blocking I/O because you can starve the common pool and stall parallel streams and other CompletableFutures. Supplying your own Executor (e.g. a bounded thread pool dedicated to I/O) isolates blocking work, gives you control over sizing, naming and rejection policy, and makes thread context predictable. Rule: keep callbacks tiny and non-blocking on the common pool, or pass a dedicated executor for anything that blocks.

code

java · 14 lines
java
ExecutorService ioPool =
        Executors.newFixedThreadPool(50, r -> {
            Thread t = new Thread(r, "io-pool");
            t.setDaemon(true);
            return t;
        });

// Blocking HTTP call isolated on a dedicated pool, NOT the common ForkJoinPool:
CompletableFuture<Response> resp =
        CompletableFuture.supplyAsync(this::buildRequest, ioPool)
                         .thenApplyAsync(this::blockingHttpCall, ioPool);

// Tiny non-blocking transform: cheap default (no hand-off needed):
CompletableFuture<Integer> len = resp.thenApply(r -> r.body().length());

go deeper

for a junior

Knows there are Async variants that run the callback on another thread, and that there's a default pool.

for a middle

Identifies the default as ForkJoinPool.commonPool() and that non-Async runs on the completing thread, sometimes inline on the caller.

for a senior

Explains common-pool starvation from blocking work and supplies a dedicated executor for I/O, with sizing/rejection reasoning.

for a principal

Sets pool topology and executor conventions across services, reasons about context propagation (MDC/thread-locals), back-pressure, ManagedBlocker, and observability of async stages.

## Two questions every transform answers: when, and where Every transform (`thenApply`, `thenAccept`, `thenRun`, `thenCompose`, …) attaches a callback. *When* it runs is fixed (after upstream completion). *Where* — on which thread — is what the `Async` suffix controls. ## Non-Async: run on the completing thread (or inline) `cf.thenApply(fn)` runs `fn` on **whichever thread completed `cf`**. Two consequences: 1. **If `cf` is already complete** when you call `thenApply`, `fn` may run **synchronously on your calling thread** — there's no hand-off. People are surprised that 'async' code ran on the main thread. 2. **If `cf` completes later**, `fn` runs on the thread that did the completing — e.g. an I/O callback thread or a ForkJoinPool worker. A heavy or blocking `fn` then occupies a thread that wasn't meant for that work, delaying other tasks queued there. Non-Async is the right default for **tiny, non-blocking** transforms (parse, arithmetic, field extraction): no thread hand-off, lowest overhead. ## Async without an executor: the common ForkJoinPool `cf.thenApplyAsync(fn)` schedules `fn` on `ForkJoinPool.commonPool()`. Key facts: - It is **shared process-wide** and also used by parallel `Stream`s and other default-async CompletableFutures. - Its parallelism defaults to **(CPU cores − 1)**, so it has very few threads. - It is designed for **CPU-bound, non-blocking** tasks. If you run **blocking** work (synchronous HTTP, JDBC, `Thread.sleep`) on it, those few threads sit idle-blocked and you can **starve** the whole pool — unrelated parallel streams and futures stall. (ForkJoinPool has a `ManagedBlocker` escape hatch, but most code doesn't use it.) ## Async with your own executor: isolation and control `cf.thenApplyAsync(fn, myExecutor)` runs `fn` on an `Executor` you supply. This gives you: - **Isolation:** blocking I/O runs on a dedicated bounded pool, never touching the common pool. - **Sizing:** an I/O pool can be much larger than CPU count since threads spend time blocked. - **Observability & control:** named threads (easier stack traces / metrics), a chosen queue and `RejectedExecutionHandler`, graceful shutdown. - **Predictable context:** consistent thread-locals / MDC handling when you control the pool. ## Practical guidance - Short, pure transforms → non-Async (or Async on the common pool if you want to free the completing thread). - Any **blocking** call → `...Async(fn, dedicatedExecutor)`. - Be deliberate: mixing non-Async after an Async stage means the next callback runs on the **previous executor's** thread unless you specify again. - Note on `thenCompose`: the `Async` suffix governs where the **composing function** runs; the **inner** future still executes on its own scheduling. ## Why the API offers both Forcing a thread hand-off on every step would add scheduling overhead to trivial maps; never offering one would make blocking callbacks unmanageable. Java exposes the choice so you can trade overhead for isolation per step.

  • Why is running blocking JDBC calls inside thenApplyAsync (no executor) risky?
    It runs on ForkJoinPool.commonPool(), which has only ~(cores-1) threads and is shared with parallel streams. Blocking calls pin those few threads, starving the pool and stalling unrelated parallel work across the JVM. Use a dedicated bounded executor for blocking I/O.
  • You call thenApply on an already-completed future. Which thread runs the function?
    The calling thread, synchronously — there is no hand-off because completion already happened. This is why 'async' transforms sometimes run inline; use thenApplyAsync if you need to guarantee execution off the caller's thread.

saying these in an interview costs you the question

  • Believing thenApply always runs on a background thread.
  • Running blocking I/O on thenXxxAsync without a custom executor (common-pool starvation).
  • Thinking the common ForkJoinPool has many threads — it's roughly CPU count.
  • Assuming Async on thenCompose moves the inner future's work too.

context