How to specify your own Executor for CompletableFuture
All Async methods have an overloaded version that accepts an Executor:
🟢 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
CompletableFuturetogether with@Async, Spring may inject its ownTaskExecutor. 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
Executoris the standard for Production. - Separate I/O and CPU workloads into different pools.
- Use
CallerRunsPolicyfor overload protection. - On Java 21, migrate to
VirtualThreadPerTaskExecutorfor 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]]