Start Learning
Javaneer
Back to stage
Stage 7·Advanced Concurrency & Modern Java

Executors & CompletableFuture

Thread pools done right, and composing asynchronous work with CompletableFuture instead of nesting callbacks.

17 min readAdvanced
On this page

Creating threads by hand with new Thread() doesn't scale - you can't manage lifecycles, limit concurrency, or reuse threads. The executor framework solves this by separating what work runs from how it's scheduled, and CompletableFuture lets you compose asynchronous results without callback nesting. Together they're how modern Java does concurrency.

Executors: pools, not raw threads

An ExecutorService manages a pool of reusable threads. You submit tasks; the pool schedules them, reusing threads instead of paying the cost of creating one per task:

ExecutorService pool = Executors.newFixedThreadPool(4);   // reuse 4 threads
Future<Integer> future = pool.submit(() -> expensiveComputation());   // returns a handle
Integer result = future.get();   // blocks until the task completes
pool.shutdown();                 // stop accepting new tasks, let running ones finish

Choosing the pool matters: a fixed pool bounds concurrency (good for CPU-bound work, sized near the core count); a cached pool grows as needed (I/O-bound bursts); and since Java 21, Executors.newVirtualThreadPerTaskExecutor() gives each task its own virtual thread (the next lesson).

CompletableFuture: composing async work

A plain Future only lets you block on get(). CompletableFuture lets you build a pipeline of asynchronous steps - transform a result, chain a dependent call, combine two futures - without ever blocking or nesting callbacks:

CompletableFuture
    .supplyAsync(() -> fetchUser(id))          // async step 1
    .thenApply(user -> user.email())           // transform the result
    .thenCompose(email -> sendAsync(email))    // chain another async call
    .thenAccept(result -> log(result))         // consume the final value
    .exceptionally(ex -> { log(ex); return null; });   // handle failures in the chain

Each stage runs when the previous completes, off the calling thread. thenCompose flattens a future of a future (like flatMap), thenCombine merges two independent futures, and exceptionally/handle manage errors along the whole pipeline - all without blocking.

thenApply vs. thenCompose

Use thenApply when your function returns a plain value (transform the result). Use thenCompose when your function returns another CompletableFuture (chain a dependent async call) - it flattens the nested future, exactly like Optional.map vs. flatMap or Stream.map vs. flatMap. Mixing them up gives you a nested CompletableFuture<CompletableFuture<T>>, a classic bug.

A kitchen with a head chef assigning tickets

Spawning a raw thread per task is like hiring a brand-new cook for every single order and firing them when the dish is done - wasteful and chaotic. An executor is a head chef with a fixed line of cooks: orders (tasks) go on a rail, and whichever cook is free takes the next one, so the same staff handles a stream of orders efficiently. CompletableFuture is the prep pipeline: chop, then sauté, then plate - each step starts when the previous finishes, and you describe the whole sequence up front rather than standing over each pot waiting.

Fetch two things in parallel, then combine

You need to call two independent slow services - fetch a user's profile and their recent orders - then combine them into a summary. Describe how to do this with CompletableFuture so the two calls run in parallel, and what you'd use to combine them.

What advantage does CompletableFuture have over a plain Future?

Key takeaways

  • Use an ExecutorService thread pool instead of raw new Thread() - it reuses threads and bounds concurrency.
  • Choose the pool for the workload: fixed (CPU-bound), cached (I/O bursts), or virtual-thread-per-task (Java 21+).
  • CompletableFuture composes asynchronous steps into a non-blocking pipeline instead of blocking on Future.get().
  • thenApply transforms a value; thenCompose chains a dependent future (flatten); thenCombine merges two independent futures.
  • Start independent async calls before joining them (thenCombine) so they run in parallel, not serially.
Was this lesson helpful?
Edit this page on GitHub