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.