⏱️ Section 19 · Question #13

How to specify your own Executor for CompletableFuture

Every asynchronous method in the CompletableFuture API provides an overloaded variant accepting a custom java.util.concurrent.Executor as its final parameter:


🟢 Junior Level

Every asynchronous method in the CompletableFuture API provides an overloaded variant accepting a custom java.util.concurrent.Executor as its final parameter:

// 1. Create a dedicated thread pool:
ExecutorService customExecutor = Executors.newFixedThreadPool(10);

// 2. Launch an asynchronous task inside the custom pool:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    System.out.println("Running in: " + Thread.currentThread().getName());
    return "Result";
}, customExecutor);

// 3. Continue the asynchronous pipeline inside the custom pool:
future.thenApplyAsync(s -> s.toUpperCase(), customExecutor)
      .thenAcceptAsync(System.out::println, customExecutor);

Why Specify a Custom Executor

  1. Bulkhead Resource Isolation: Prevents slow or blocking I/O tasks from starving the shared ForkJoinPool.commonPool() and parallel streams.
  2. Concurrency Rate Limiting: Caps the maximum number of concurrent database connections or external API calls.
  3. Observability and Diagnostics: Meaningful thread names (payment-worker-1, db-reader-2) make profiling and inspecting JVM thread dumps (jstack) straightforward.

🟡 Middle Level

The Executor Non-Inheritance Trap

In CompletableFuture, an Executor is not inherited downstream. If you provide a custom pool to supplyAsync(), but omit it in a subsequent *Async call, execution silently escapes into ForkJoinPool.commonPool():

// ANTI-PATTERN: First stage runs in customExecutor, second escapes to commonPool!
CompletableFuture.supplyAsync(() -> downloadFile(), ioExecutor)
    .thenApplyAsync(file -> processFile(file)); // SILENT LEAK TO commonPool!

// CORRECT: Pass the executor explicitly at every asynchronous stage
CompletableFuture.supplyAsync(() -> downloadFile(), ioExecutor)
    .thenApplyAsync(file -> processFile(file), ioExecutor);

Production Thread Pool Sizing and Backpressure

High-throughput systems avoid Executors.newCachedThreadPool() (unbounded thread spawning risks OOM) and unbounded queues like new LinkedBlockingQueue<>() (risks out-of-memory errors from backlog accumulation):

@Bean(name = "orderProcessingExecutor")
public ExecutorService orderProcessingExecutor() {
    return new ThreadPoolExecutor(
        10,                             // corePoolSize
        50,                             // maximumPoolSize
        60L, TimeUnit.SECONDS,          // keepAliveTime for idle threads
        new ArrayBlockingQueue<>(500),   // BOUNDED QUEUE
        new ThreadFactoryBuilder()
            .setNameFormat("order-exec-%d")
            .setDaemon(true)
            .build(),
        new ThreadPoolExecutor.CallerRunsPolicy() // Graceful backpressure
    );
}

[!TIP] The CallerRunsPolicy forces the calling thread (e.g., Tomcat HTTP worker) to execute the rejected task inline when the queue fills. This naturally throttles incoming request rates without throwing errors.

Virtual Threads (Java 21+) for Asynchronous I/O

For blocking I/O operations (HTTP clients, JDBC queries, message queues), Java 21+ replaces fixed thread pools with lightweight virtual threads:

ExecutorService vtExecutor = Executors.newVirtualThreadPerTaskExecutor();

CompletableFuture.supplyAsync(() -> httpClient.get(url), vtExecutor);

🔴 Senior Level

Overriding defaultExecutor() (Java 9+)

Starting with Java 9, CompletableFuture exposes a protected method:

public Executor defaultExecutor() {
    return ASYNC_POOL;
}

Framework and library designers can subclass CompletableFuture to alter the default executor across all parameterless *Async() methods without requiring developers to pass the executor explicitly at every stage:

public class CustomCompletableFuture<T> extends CompletableFuture<T> {
    private static final ExecutorService DEDICATED_POOL = Executors.newFixedThreadPool(32);

    @Override
    public Executor defaultExecutor() {
        return DEDICATED_POOL; // Overrides commonPool for the entire pipeline!
    }

    @Override
    public <U> CompletableFuture<U> newIncompleteFuture() {
        return new CustomCompletableFuture<>();
    }
}

The Brian Goetz Thread Pool Sizing Formula

Optimal pool sizing is calculated based on workload profiles:

\[N_{\text{threads}} = N_{\text{cpu}} \times U_{\text{cpu}} \times \left(1 + \frac{W}{C}\right)\]

Where:

  • $N_{\text{cpu}}$: Number of available CPU cores.
  • $U_{\text{cpu}}$: Target CPU utilization ($0 \le U_{\text{cpu}} \le 1$).
  • $W$: Wait time (time spent waiting for I/O, database, or network).
  • $C$: Compute time (active CPU processing time).

Example: On an 8-core CPU running a service where database latency is 90 ms ($W = 90$) and parsing takes 10 ms ($C = 10$): \(N = 8 \times 1.0 \times \left(1 + \frac{90}{10}\right) = 8 \times 10 = 80 \text{ threads}.\)


4 Tricky Questions

1. Does CompletableFuture.allOf() accept an Executor parameter? How do you process its completion in a custom thread pool?

Answer: No, CompletableFuture.allOf() has no overload accepting an Executor. Its signature is strictly allOf(CompletableFuture<?>... cfs) because it only constructs a coordination barrier. To execute downstream processing within a custom thread pool, pass the Executor to the chained continuation method:

CompletableFuture.allOf(f1, f2, f3)
    .thenRunAsync(() -> processAggregatedResults(), customExecutor);

2. What happens if a custom ThreadPoolExecutor’s queue is saturated and its handler is set to AbortPolicy?

Answer: The pool throws a RejectedExecutionException:

  • If rejection occurs during pipeline initiation via CompletableFuture.supplyAsync(supplier, fullExecutor), the RejectedExecutionException is thrown synchronously in the calling thread.
  • If rejection occurs at an intermediate continuation stage like .thenApplyAsync(fn, fullExecutor), the RejectedExecutionException is caught by CompletableFuture and wrapped in a CompletionException, failing the downstream stage.

3. Why is pairing Executors.newCachedThreadPool() with CompletableFuture dangerous under high load?

Answer: Executors.newCachedThreadPool() configures corePoolSize = 0, maximumPoolSize = Integer.MAX_VALUE, and uses a SynchronousQueue. Under a sudden traffic spike of 10,000 concurrent requests, the pool attempts to spawn 10,000 native OS threads. Each OS thread allocates a stack (~1 MB via -Xss), rapidly consuming gigabytes of native memory, overwhelming the OS scheduler with context switches, and causing java.lang.OutOfMemoryError: unable to create new native thread.

4. What happens if you pass null as the executor: CompletableFuture.supplyAsync(task, null)?

Answer: It throws a NullPointerException immediately. Inside CompletableFuture, the executor argument is validated by:

static Executor screenExecutor(Executor e) {
    if (e == null) throw new NullPointerException();
    return e;
}

Passing null does not fall back to ForkJoinPool.commonPool(); to use the default pool, invoke the single-argument overload supplyAsync(task).


🎯 Interview Cheat Sheet

Summary in 60 Seconds

  • Provide a custom pool by passing Executor as the second argument to supplyAsync, runAsync, thenApplyAsync, etc.
  • In CompletableFuture, the Executor never propagates down the chain automatically; omitted executors revert to commonPool().
  • Production requirements:
    • Bounded queue (ArrayBlockingQueue).
    • Meaningful thread naming via ThreadFactory.
    • Backpressure via CallerRunsPolicy.
    • Java 21+ Virtual Threads (Executors.newVirtualThreadPerTaskExecutor()) for I/O.
  • Subclasses can override defaultExecutor() (Java 9+) to redirect parameterless *Async methods across an entire pipeline.

Common Interview Mistakes

  • Assuming passing an executor to supplyAsync() covers the rest of the chained *Async methods.
  • Using newCachedThreadPool() for production I/O workloads.
  • Passing null as an executor expecting default behavior (triggers NullPointerException).