Q33 of 40 · Core Java
How do CompletableFuture chains work? When are they better than ExecutorService.submit?
Core JavaSeniorcompletablefutureasyncconcurrencyjava-8futuresexecutorservice
Short answer
Short answer: CompletableFuture (CF) is an implementation of Future and CompletionStage that supports non-blocking callback chains, explicit completion, and combinator methods (thenApply, thenCompose, allOf, anyOf). Unlike ExecutorService.submit which blocks on .get(), CF lets you register continuations that run when the result is ready — on the common pool or a custom executor.
Detail
Key CompletionStage methods
| Method | Input | Output | Use case |
|---|---|---|---|
thenApply(fn) |
T | U | transform result (sync mapping) |
thenCompose(fn) |
T | CF | flatMap — chain async steps |
thenAccept(fn) |
T | void | consume result, no return |
thenRun(fn) |
— | void | run after completion |
exceptionally(fn) |
Throwable | T | recover on failure |
handle(fn) |
T, Throwable | U | success or failure in one handler |
allOf(cf...) |
— | CF |
wait for all to complete |
anyOf(cf...) |
— | CF | first one wins |
import java.util.concurrent.CompletableFuture;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
var http = HttpClient.newHttpClient();
// Fan-out: fire 3 API calls concurrently, combine results
var userCF = CompletableFuture.supplyAsync(() ->
fetchUser(http, 42));
var ordersCF = CompletableFuture.supplyAsync(() ->
fetchOrders(http, 42));
var preferencesCF = CompletableFuture.supplyAsync(() ->
fetchPreferences(http, 42));
CompletableFuture.allOf(userCF, ordersCF, preferencesCF)
.thenRun(() -> {
var user = userCF.join(); // safe after allOf
var orders = ordersCF.join();
var prefs = preferencesCF.join();
buildDashboard(user, orders, prefs);
})
.exceptionally(ex -> {
System.err.println("Dashboard load failed: " + ex.getMessage());
return null;
});
vs ExecutorService
// ExecutorService — thread blocks on get()
Future<String> f = executor.submit(() -> fetchUser(http, 42));
String user = f.get(); // BLOCKS calling thread until done
// CompletableFuture — calling thread is free; callback fires when ready
CompletableFuture.supplyAsync(() -> fetchUser(http, 42))
.thenAccept(user -> process(user)); // returns immediately
When to prefer CF
- Fan-out/fan-in: fire N tasks, combine results without blocking a thread per task
- Chained async steps: avoid nested Futures or callback hell
- Timeout and fallback:
orTimeout()(Java 9),completeOnTimeout() - Combining heterogeneous async results from different services
When to stick with ExecutorService
- Simple parallel batch jobs where you need all results before proceeding and blocking is fine
- CPU-bound work with a bounded ForkJoinPool or FixedThreadPool
// WHAT INTERVIEWERS LOOK FOR
The difference between thenApply (sync transform) and thenCompose (async flatMap). How allOf enables fan-out without blocking. The danger of join() inside a thenApply that's running on the common pool (can starve the pool). Strong candidates mention custom executor parameters to avoid common pool exhaustion.
// COMMON PITFALL
Calling join() or get() inside a CF callback running on the ForkJoinPool common pool. This blocks one of the carrier threads, reducing parallelism. Always chain continuations or use a dedicated executor for blocking calls inside CF pipelines.