05 · Concurrency Primer — The Functional Async Primitive¶
You have spent this phase making synchronous code readable. Concurrency is where that discipline pays or breaks: a badly composed CompletableFuture chain becomes a callback-hell relic, but a well-composed one reads like a stream pipeline for async values. This file is a bridge, not a full concurrency course — Phase 05 handles the JMM, virtual threads, and structured concurrency in depth. Your goal here is to learn CompletableFuture (CF) as a functional type that composes, and to know when Java 21 virtual threads make CF chains obsolete.
Why CompletableFuture Exists¶
Future<T> (Java 5) let you get() a value later — but get() blocks, and you cannot chain, combine, or handle errors without blocking a thread per operation. CompletableFuture<T> (Java 8) fixes this by exposing a monadic API: every operation returns a new CF, so you describe the pipeline once and the runtime executes it on a thread pool.
Mental model: CF is Optional for time. Both wrap a value that may or may not be available; both give you map-style operations to transform without unwrapping.
|
|
Meaning |
|---|---|---|
|
|
Transform value if present |
|
|
Chain another wrapped op |
|
|
Fallback if empty/failed |
|
|
Side-effect on value |
Creating a CompletableFuture¶
Three sources cover 95% of real code:
// 1. Wrap a synchronous value (already complete)
CompletableFuture<String> done = CompletableFuture.completedFuture("ready");
// 2. Run async, no return (Runnable)
CompletableFuture<Void> fire = CompletableFuture.runAsync(() -> log("started"));
// 3. Compute async, returns a value (Supplier) — the workhorse
CompletableFuture<User> user = CompletableFuture.supplyAsync(() -> userRepo.find(id));
By default, runAsync/supplyAsync submit work to the common ForkJoinPool. For I/O-heavy tasks pass your own executor — the common pool is CPU-sized and blocking I/O starves it.
ExecutorService io = Executors.newFixedThreadPool(32);
CompletableFuture<User> user = CompletableFuture.supplyAsync(() -> userRepo.find(id), io);
The Three Composition Operators You Actually Use¶
Nearly every real chain is built from thenApply, thenCompose, and thenCombine. Learn these three cold; the other 40 methods are variations.
thenApply — Transform (Function<T, R>)¶
Same async value, new shape. Do NOT do async work inside — this runs on the completing thread.
CompletableFuture<String> name =
CompletableFuture.supplyAsync(() -> userRepo.find(id)) // CF<User>
.thenApply(User::name); // CF<String>
thenCompose — Flat-map (Function<T, CF>)¶
The one that saves you from CompletableFuture<CompletableFuture<Order>>. Use whenever the next step is itself async.
CompletableFuture<Order> order =
CompletableFuture.supplyAsync(() -> userRepo.find(userId))
.thenCompose(user ->
CompletableFuture.supplyAsync(() -> orderRepo.latestFor(user)));
If you had used thenApply here, you would get CF<CF<Order>> — the exact same trap as Optional<Optional<T>> from the last file.
thenCombine — Zip two independent CFs (BiFunction<T, U, R>)¶
Run two async ops in parallel, join their results.
CompletableFuture<User> userCf = CompletableFuture.supplyAsync(() -> userRepo.find(id), io);
CompletableFuture<Cart> cartCf = CompletableFuture.supplyAsync(() -> cartRepo.find(id), io);
CompletableFuture<Checkout> checkout =
userCf.thenCombine(cartCf, (u, c) -> new Checkout(u, c));
Both fire simultaneously; the BiFunction runs when both complete. Beats sequential thenCompose by the latency of the slower call.
⚠️ What Most People Get Wrong
They call
.get()on a CompletableFuture inside another lambda. That blocks a pooled thread and defeats the whole point. Chain withthenCompose/thenCombine; only.get()(or better,.join()) at the very edge of your program — the servlet response, the CLI’s main thread, the test assertion. If you find yourself blocking in the middle of a pipeline, refactor.
Exception Handling — exceptionally, handle, whenComplete¶
Exceptions inside a CF chain do not bubble like sync code — they wrap into a CompletionException and short-circuit downstream steps. You need explicit recovery.
Operator |
Signature |
Use when |
|---|---|---|
|
|
Fallback value on failure, ignore success |
|
|
Transform both outcomes into one shape |
|
|
Side-effect (log/metrics), no transform |
CompletableFuture<Price> price =
priceService.quoteAsync(sku)
.exceptionally(ex -> {
log.warn("quote failed for {}", sku, ex);
return Price.unavailable(); // fallback
});
CompletableFuture<PriceResult> result =
priceService.quoteAsync(sku)
.handle((p, ex) -> ex == null
? PriceResult.ok(p)
: PriceResult.error(ex.getMessage())); // unify outcomes
priceService.quoteAsync(sku)
.whenComplete((p, ex) -> metrics.record("quote", ex == null)); // observe only
Prefer handle over exceptionally when the success and failure types differ — it forces you to think about both branches. If you already have a sealed Result<T, E> from Phase 03, wrap here and the rest of your code stays typed.
A Realistic Chain¶
Fetch user + cart in parallel, then price the cart, then apply a discount — with a fallback if pricing fails:
public CompletableFuture<Checkout> buildCheckout(long userId) {
var userCf = CompletableFuture.supplyAsync(() -> userRepo.find(userId), io);
var cartCf = CompletableFuture.supplyAsync(() -> cartRepo.find(userId), io);
return userCf.thenCombine(cartCf, Pair::new)
.thenCompose(p ->
pricingService.quoteAsync(p.cart()) // CF<Price>
.exceptionally(ex -> Price.fallback()) // never fails
.thenApply(price -> new Checkout(p.user(), p.cart(), price)))
.thenApply(discountService::applyLoyalty); // sync tail
}
Read it top-to-bottom: fetch two things in parallel, zip, kick off async pricing with fallback, apply a sync transform. That is the whole grammar.
thenApplyAsync — When to Add the Async Suffix¶
Every composition operator has an Async variant: thenApplyAsync, thenComposeAsync, etc. The rule:
No
Asyncsuffix — runs on whichever thread completed the previous stage. Cheap for pure transforms.Asyncsuffix — resubmits the step to the pool. Use when the step is blocking (DB, HTTP, disk) or CPU-heavy.
cf.thenApply(User::name) // fine — cheap
.thenApplyAsync(this::enrichFromDb, io) // blocks — must go async on I/O pool
.thenApply(String::toUpperCase); // fine — cheap
Passing your own Executor is the difference between a program that scales and one that deadlocks on the common pool. Make it a habit.
allOf and anyOf — Fan-out / Fan-in¶
Two collection-level operators for the common “wait for N” and “wait for any” cases.
List<CompletableFuture<Product>> lookups = skus.stream()
.map(sku -> CompletableFuture.supplyAsync(() -> catalog.find(sku), io))
.toList();
CompletableFuture<List<Product>> all =
CompletableFuture.allOf(lookups.toArray(CompletableFuture[]::new))
.thenApply(v -> lookups.stream().map(CompletableFuture::join).toList());
allOf returns CF<Void> — the idiom above is how you recover the results. anyOf returns CF<Object> (untyped, sadly) — useful for racing redundant data sources.
Enter Java 21: Virtual Threads Change the Calculus¶
Before Java 21, CF chains existed because platform threads are expensive (~1 MB stack each). You could not afford one platform thread per in-flight request. So you wrote non-blocking code with CF or Reactor.
Virtual threads (Project Loom, stable in Java 21) invert this. A virtual thread costs ~500 bytes and blocks cheaply — the JVM unmounts it from the carrier when it blocks on I/O. Suddenly, this synchronous code is fine at scale:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<User> userF = executor.submit(() -> userRepo.find(id));
Future<Cart> cartF = executor.submit(() -> cartRepo.find(id));
return new Checkout(userF.get(), cartF.get()); // blocking is OK now
}
That reads exactly like sync code and scales to millions of concurrent requests. You get to drop most CF chains.
When to Prefer Virtual Threads Over CompletableFuture¶
Situation |
Prefer |
|---|---|
Server handling many blocking I/O requests (JDBC, HTTP client, file) |
Virtual threads |
Composing async APIs that already return |
CompletableFuture |
CPU-bound parallel work |
Neither — use ForkJoinPool / parallel streams |
Fan-out to many services and gather |
Virtual threads with structured concurrency ( |
Existing reactive codebase (Reactor, RxJava) |
Stay reactive; don’t mix paradigms |
Structured concurrency (Java 21 preview / 23 second preview) is the real endpoint — it makes virtual-thread fan-out safe and cancellable, and will likely be the enterprise default by the time you finish this roadmap. Phase 05 goes deep.
⚠️ What Most People Get Wrong
They pin a virtual thread with
synchronized. If a virtual thread hits asynchronizedblock that then does blocking I/O, the JVM cannot unmount it — it stays pinned to its carrier and you lose the scaling win. UseReentrantLockfor any critical section that might block. JEP 491 (Java 24) removes most pinning, but until your prod JDK is on 24 the rule stands: nosynchronizedaround I/O in virtual-thread code.
Anti-Patterns to Recognise in Reviews¶
.get()in the middle of a chain — blocks a pooled thread. Fix withthenCompose.No custom executor — every
supplyAsyncon the common pool. Under load, everything queues behind everything.Swallowed exceptions — a CF that fails silently because nothing called
handle/exceptionally/join. Chain.whenComplete((v, ex) -> log(...))at least at the tail.thenApplydoing blocking work — the transform runs on the completing thread; blocking here poisons whichever pool completed the previous stage.Reinventing
allOf— collecting CFs into a list, then looping.join()on each sequentially. That’s serial, not parallel.
Testing Async Code¶
Two rules keep tests deterministic:
Use
.join()(not.get()) at the assertion boundary.join()throws unchecked, matches assertion style.Pass a synchronous executor when possible.
Runnable::runas the executor makes CF chains fully deterministic in tests without changing production code.
Executor sync = Runnable::run;
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> "hi", sync);
assertEquals("hi", cf.join());
For flakier integration tests, Awaitility (await().atMost(2, SECONDS).until(...)) beats Thread.sleep.
Bridge to Phase 05¶
Phase 05 (Concurrency & Performance) picks up where this file ends:
Java Memory Model,
volatile, happens-beforeExecutorServicedesign, pool sizing (Little’s Law)Virtual threads deep dive, structured concurrency,
ScopedValueLocks vs
AtomicReferencevs concurrent collectionsDiagnosing deadlocks, contention, and thread starvation with JFR
For now: understand CF as the functional composition of async values, know the three composition operators, know when virtual threads let you skip CF entirely, and never block a pool thread inside a chain.
What to Practice This Week¶
Take one synchronous service method that does 2-3 sequential I/O calls and rewrite it as a CF chain with
thenCombinewhere possible. Measure latency.Add
.exceptionallyorhandleso every chain has a well-defined failure value — no unhandledCompletionExceptions.Rewrite the same method a third time using virtual threads (
Executors.newVirtualThreadPerTaskExecutor()). Compare readability and throughput.Read Brian Goetz’s talk “Java’s Virtual Threads: A Comprehensive Introduction” and JEP 444 (Virtual Threads final).
Return to README.md · Previous: 04_optional_done_right.md · Next: projects.md