Question 13 · Section 19

How to specify your own Executor for CompletableFuture

All Async methods have an overloaded version that accepts an Executor:

Language versions: English Russian Ukrainian

🟢 Junior Level

All *Async methods have an overloaded version that accepts an Executor:

ExecutorService executor = Executors.newFixedThreadPool(10);

CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
    return fetchData();
}, executor);  // custom Executor

cf.thenApplyAsync(s -> transform(s), executor);  // also custom Executor

Why:

  • Control over the number of threads
  • Isolation from other tasks
  • Monitoring capability

🟡 Middle Level

Pool selection strategies

I/O-Bound (HTTP, DB, Files):

Executor ioExecutor = new ThreadPoolExecutor(
    10, 100, 60L, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(1000),  // Bounded queue is mandatory!
    new ThreadFactoryBuilder().setNameFormat("io-pool-%d").build(),
    new ThreadPoolExecutor.CallerRunsPolicy()  // Backpressure
);

CPU-Bound (Calculations, Mapping):

Executor cpuExecutor = Executors.newFixedThreadPool(
    Runtime.getRuntime().availableProcessors()
);

Virtual Threads (Java 21+):

Executor vtExecutor = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture.supplyAsync(task, vtExecutor);

The Executor passing trap

Executor must be passed at every async stage:

// ❌ Bad: only the first stage in its own pool, the rest — in commonPool
CF.supplyAsync(task, myExecutor)
  .thenApplyAsync(transform);  // commonPool!

// ✅ Good: isolation maintained
CF.supplyAsync(task, myExecutor)
  .thenApplyAsync(transform, myExecutor);

Lifecycle Management

@PreDestroy
public void shutdown() {
    ((ExecutorService) executor).shutdown();
}

Without shutdown() — “zombie threads” that prevent proper process termination.


🔴 Senior Level

Internal Implementation

CallerRunsPolicy as Backpressure:

When the queue is full → the task runs in the calling thread.
This naturally slows down the request producer.

Thread Naming: Always use ThreadFactory (Guava, Spring) to give threads names. Without this, analyzing heap dumps during incidents becomes impossible.

Advanced techniques: ManagedBlocker

If you use ForkJoinPool (including commonPool) for tasks that may block, you can use the ManagedBlocker interface.

  • Under the hood: This allows the pool to dynamically create a new thread to compensate for the blocked one, maintaining the parallelism level. It is more complex to implement than a simple FixedThreadPool, but much more efficient.
// Example ManagedBlocker implementation for blocking operations:
class BlockingAdapter<T> implements ForkJoinPool.ManagedBlocker {
    private T result;
    private final Supplier<T> task;
    private boolean isDone = false;

    public boolean block() {
        result = task.get();  // blocking operation
        isDone = true;
        return true;
    }
    public boolean isReleasable() { return isDone; }
    public T getResult() { return result; }
}

Architectural Trade-offs

Approach Pros Cons
FixedThreadPool Predictable size No work-stealing
CachedThreadPool Auto-scaling Unbounded threads
Virtual Threads Ideal for I/O Java 21+ only
ManagedBlocker Blocking compensation Complex implementation

Edge Cases

  • Spring @Async: If you use CompletableFuture together with @Async, Spring may inject its own TaskExecutor. Make sure you control its configuration.

When NOT to use custom Executor

  • CPU-bound tasks — ForkJoinPool.commonPool() is optimal (work-stealing)
  • Few tasks — pool creation overhead > benefit

Best Practices

// ✅ Always your own Executor for production
CompletableFuture.supplyAsync(task, ioExecutor);

// ✅ CallerRunsPolicy for backpressure
new ThreadPoolExecutor.CallerRunsPolicy();

// ✅ Thread naming for diagnostics
new ThreadFactoryBuilder().setNameFormat("pool-%d").build();

// ✅ Virtual Threads for I/O (Java 21+)
Executors.newVirtualThreadPerTaskExecutor();

// ❌ Without shutdown() on exit
// ❌ commonPool for I/O
// ❌ Unbounded queues

Summary for Senior

  • Passing an Executor is the standard for Production.
  • Separate I/O and CPU workloads into different pools.
  • Use CallerRunsPolicy for overload protection.
  • On Java 21, migrate to VirtualThreadPerTaskExecutor for I/O tasks.

🎯 Interview Cheat Sheet

Must know:

  • All *Async methods have an overloaded version with Executor
  • I/O-Bound: ThreadPoolExecutor(core=10, max=100, CallerRunsPolicy)
  • CPU-Bound: FixedThreadPool(availableProcessors)
  • Virtual Threads (Java 21+): Executors.newVirtualThreadPerTaskExecutor() — ideal for I/O
  • Executor must be passed at EVERY async stage of the chain

Frequent follow-up questions:

  • Why CallerRunsPolicy? — Backpressure: when the queue overflows, the task runs in the calling thread, slowing down the producer
  • Why ThreadFactory with names? — Diagnostics: without thread names, jstack analysis is impossible
  • What if you don’t pass Executor to thenApplyAsync? — commonPool is used — isolation lost
  • Why ManagedBlocker? — Blocking compensation in ForkJoinPool: pool creates an extra thread in place of the blocked one

Red flags (DO NOT say):

  • “CachedThreadPool for production” — unbounded threads → OOM
  • “Executor is only needed at the first stage” — every *Async without Executor goes to commonPool
  • “Without shutdown() is fine” — zombie threads prevent process termination

Related topics:

  • [[12. What thread pool is used by default for async methods]]
  • [[15. Why is it important to avoid blocking operations in CompletableFuture]]
  • [[11. What is the difference between thenApply() and thenApplyAsync()]]
  • [[16. What does supplyAsync() method do and when to use it]]