What is the difference between thenApply() and thenApplyAsync()
Both methods transform the result of a CompletableFuture, but in different threads:
🟢 Junior Level
Both methods transform the result of a CompletableFuture, but in different threads:
thenApply()— executes in the same thread that completed the previous CFthenApplyAsync()— executes in a different thread (ForkJoinPool or specified Executor)
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
System.out.println("Supply: " + Thread.currentThread().getName());
return "Hello";
});
// thenApply — same thread
cf.thenApply(s -> {
System.out.println("thenApply: " + Thread.currentThread().getName());
return s + " World";
});
// thenApplyAsync — different thread (ForkJoinPool)
cf.thenApplyAsync(s -> {
System.out.println("thenApplyAsync: " + Thread.currentThread().getName());
return s + " World";
});
🟡 Middle Level
When to use which
thenApply — for lightweight operations:
// Lightweight transformation — no point switching threads
cf.thenApply(s -> s.toUpperCase())
.thenApply(s -> s.trim())
.thenAccept(System.out::println);
thenApplyAsync — for heavy operations:
// Heavy processing — better in another thread
cf.thenApplyAsync(s -> heavyProcessing(s), executor)
.thenAccept(System.out::println);
With custom Executor:
ExecutorService executor = Executors.newFixedThreadPool(10);
cf.thenApplyAsync(s -> transform(s), executor);
Typical mistakes
- thenApplyAsync without Executor: ```java // Uses ForkJoinPool.commonPool() cf.thenApplyAsync(s -> s.toUpperCase());
// ⚠️ commonPool has a limited number of threads // For I/O operations — bad
// ✅ Custom Executor cf.thenApplyAsync(s -> s.toUpperCase(), ioExecutor);
---
## 🔴 Senior Level
### Internal Implementation
**thenApply:**
```java
public <U> CompletableFuture<U> thenApply(Function<? super T,? extends U> fn) {
return uniApplyStage(null, fn);
}
// Executes in the thread that completed the previous CF
// No thread switch — minimal overhead
thenApplyAsync:
public <U> CompletableFuture<U> thenApplyAsync(Function<? super T,? extends U> fn) {
return uniApplyStage(asyncPool, fn); // asyncPool = ForkJoinPool.commonPool()
}
public <U> CompletableFuture<U> thenApplyAsync(
Function<? super T,? extends U> fn, Executor executor
) {
return uniApplyStage(screenExecutor(executor), fn);
}
// asyncPool — thread from ForkJoinPool.commonPool()
// or the specified executor
Architectural Trade-offs
| thenApply | thenApplyAsync |
|---|---|
| Same thread | Different thread |
| ~5 ns overhead | ~1μs overhead |
| For lightweight operations | For heavy/blocking |
| No context switch | Thread switch |
Edge Cases
1. ThreadLocal:
// thenApply — ThreadLocal is preserved
ThreadLocal<String> context = new ThreadLocal<>();
context.set("value");
cf.thenApply(s -> {
String ctx = context.get(); // OK — same thread
return s + ctx;
});
// thenApplyAsync — ThreadLocal is lost
cf.thenApplyAsync(s -> {
String ctx = context.get(); // null! — different thread
return s;
});
2. Blocking operations:
// ❌ thenApply with a blocking operation
cf.thenApply(s -> {
Thread.sleep(1000); // blocks the ForkJoinPool thread!
return s;
});
// ✅ thenApplyAsync with a separate Executor
cf.thenApplyAsync(s -> {
Thread.sleep(1000);
return s;
}, ioExecutor);
Performance
thenApply:
- Overhead: ~5 ns
- No thread switch
thenApplyAsync (commonPool):
- Overhead: ~1μs
- Thread switch + queue
thenApplyAsync (custom executor):
- Overhead: ~2-5μs
- Thread switch + queue
For lightweight operations: thenApply is significantly faster than thenApplyAsync
(no scheduling overhead), but exact ratios depend on JVM, load, and hardware.
When NOT to use thenApplyAsync
- ThreadLocal context — Async may switch threads, ThreadLocal is lost
- Trivial transformations (getLength, toString) — scheduling overhead > benefit
Production Experience
Pipeline:
// thenApply for lightweight transformations
fetchDataAsync()
.thenApply(data -> parseJson(data)) // lightweight
.thenApply(obj -> validate(obj)) // lightweight
.thenApplyAsync(valid -> transform(valid), transformExecutor) // heavy
.thenAcceptAsync(result -> save(result), ioExecutor); // I/O
Best Practices
// ✅ thenApply for lightweight operations
cf.thenApply(s -> s.toUpperCase());
// ✅ thenApplyAsync for heavy/blocking
cf.thenApplyAsync(s -> heavyProcessing(s), executor);
// ✅ Custom Executor for I/O
cf.thenApplyAsync(s -> ioOperation(s), ioExecutor);
// ❌ thenApplyAsync without reason (overhead)
// ❌ thenApply with blocking operations
// ❌ commonPool for I/O
🎯 Interview Cheat Sheet
Must know:
- thenApply — in the same thread as the previous CF, ~5 ns overhead
- thenApplyAsync — in ForkJoinPool.commonPool() or specified Executor, ~1μs overhead
- thenApply for lightweight CPU transformations, thenApplyAsync for heavy/blocking
- ThreadLocal is lost with Async — different thread
- Without Executor, thenApplyAsync uses ForkJoinPool.commonPool()
Frequent follow-up questions:
- When thenApply, when thenApplyAsync? — thenApply for lightweight (toUpperCase), thenApplyAsync for heavy (heavyProcessing, I/O)
- thenApplyAsync without Executor — which pool? — ForkJoinPool.commonPool(), for I/O this is thread pool starvation
- Why is thenApply faster? — No thread switch, queue, or scheduling overhead
- ThreadLocal with thenApplyAsync? — Will be lost, since it’s a different thread. Need explicit context passing
Red flags (DO NOT say):
- “thenApplyAsync is always better — it’s async” — +~1μs overhead, excessive for lightweight operations
- “thenApply blocks the thread” — it executes in the thread that completed the CF, no additional blocking
- “thenApplyAsync without Executor is safe for I/O” — uses commonPool, thread pool starvation
Related topics:
- [[12. What thread pool is used by default for async methods]]
- [[13. How to specify your own Executor for CompletableFuture]]
- [[4. What is the difference between thenApply() and thenCompose()]]
- [[15. Why is it important to avoid blocking operations in CompletableFuture]]