BiblioTech already does things in parallel. It sends two hundred notices with a bounded pool, protects its catalogue with concurrent structures and counts its statistics without locks. But there is one gesture repeated throughout all the code written so far that gives away the limit of the model: future.get().
Every time the application needs the result of something, somebody stands still waiting for it. And if the work has several steps —query the catalogue, calculate the fines from that result, export the report— you have to get() between each one, so the orchestrating thread spends most of its time blocked. Future knows how to say "a result will arrive here"; it does not know how to say "when it is ready, do this next".
CompletableFuture (Java 8) is that answer. It changes the model completely: instead of asking and waiting, you declare the whole chain up front and each stage fires by itself when the previous one finishes. No thread waits for anybody.
This is the module's closing lesson. By the end, BiblioTech will have an asynchronous chain that queries, calculates and exports without blocking the menu for a millisecond, and module 8 will be complete.
An honest warning.
CompletableFuturehas a broad API —over fifty methods— and it is easy to write unreadable chains or, worse, chains that look asynchronous and block inside. This lesson focuses on the subset that solves 95% of cases and on the traps that make the remaining 5% worse than the blocking code they replace.
Contents
- The four limitations of
Future - What a
CompletableFutureis - Creation:
supplyAsync,runAsync,completedFuture - The default executor and why you should pass your own
- Transformation:
thenApply,thenAccept,thenRun thenApplyversusthenCompose- The
...Asyncvariants and which thread runs each stage - Combination:
thenCombine allOfandanyOf- Errors:
exceptionally,handle,whenComplete - How an exception travels through the chain
- Deadlines:
orTimeoutandcompleteOnTimeout - Completing manually: adapting a callback API
- Cancellation and its limits
- Good practice and traps
- BiblioTech: the complete asynchronous chain
- Comparison with the reactive model and virtual threads
- Common Mistakes and Tips
- Exercises
- The four limitations of
Future
FutureFuture was a great step forward in Java 5, but its API has five methods and none of them allows composition.
// The problem, in real BiblioTech code.
ExecutorService executor = Executors.newFixedThreadPool(4);
Future<Catalog> f1 = executor.submit(() -> loadCatalog());
Catalog c = f1.get(); // BLOCKS. The thread stops here.
Future<Double> f2 = executor.submit(() -> calculateFines(c));
double total = f2.get(); // BLOCKS again.
Future<Path> f3 = executor.submit(() -> export(total));
Path report = f3.get(); // And again.Three asynchronous tasks, and the orchestrating thread has been blocked practically the whole time. The concurrency is in the tasks, not in the coordination.
Future limitation |
What it implies |
|---|---|
| You cannot chain | There is no way to say "when it finishes, do this with the result" |
| You cannot combine | To join two independent results you have to get() both |
get() blocks |
The orchestrating thread stops; with several steps, it stops several times |
| There are no callbacks | You cannot register code to run on completion |
| You cannot complete it by hand | It is no use for adapting callback-based APIs |
What you would like to write is this:
// The same with CompletableFuture: the chain is DECLARED and it returns
// immediately. No thread blocks at any point.
CompletableFuture<Path> report =
CompletableFuture.supplyAsync(() -> loadCatalog(), ioPool)
.thenApplyAsync(c -> calculateFines(c), computePool)
.thenApplyAsync(total -> export(total), ioPool)
.exceptionally(error -> errorPath(error));
System.out.println("[main] chain launched; the menu is still alive");
showMenu(); // the main thread has NOT waited for anythingFive lines describing the entire flow, error handling included, with not a single block.
- What a
CompletableFuture is
CompletableFuture isCompletableFuture<T> implements Future<T> —so it still has get(), cancel() and isDone()— and adds two capabilities:
- It is completable: it can be completed manually from outside with
complete(value). - It is composable: you can chain stages that run on completion, without blocking.
The <T> is, as always, the type of the result: a CompletableFuture<Catalog> promises a Catalog; a CompletableFuture<String> promises a String. Generics are studied in 10-01.
The right mental model is a pipeline: you declare the stages up front, and each one fires when the previous one hands it a value.
flowchart LR
A["supplyAsync<br/>load catalogue"] -->|"Catalog"| B["thenApply<br/>calculate fines"]
B -->|"Double"| C["thenApply<br/>format report"]
C -->|"String"| D["thenAccept<br/>write file"]
D --> E["Completed"]
A -.->|"exception"| F["exceptionally<br/>fallback value"]
B -.->|"exception"| F
C -.->|"exception"| F
F --> E
The solid arrows are the success path: each stage receives the previous one's result. The dashed ones are the error path: an exception in any stage jumps straight to the handler, without running the intermediate stages. It is the same model as a try/catch wrapping the whole sequence, but spread over time and without blocking.
CompletableFuture also implements CompletionStage<T>, the interface defining all the composition methods. In practice you will work with the class; the interface turns up in library method signatures.
- Creation:
supplyAsync, runAsync, completedFuture
supplyAsync, runAsync, completedFutureimport java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
// 1. supplyAsync: runs a Supplier and RETURNS a value.
// It is the most frequent entry point.
CompletableFuture<Catalog> f1 =
CompletableFuture.supplyAsync(() -> loadCatalog());
// With your own executor (recommended, section 4):
CompletableFuture<Catalog> f2 =
CompletableFuture.supplyAsync(() -> loadCatalog(), ioPool);
// 2. runAsync: runs a Runnable, returns NO value.
// The type is CompletableFuture<Void>.
CompletableFuture<Void> f3 =
CompletableFuture.runAsync(() -> recordAudit("start-up"), ioPool);
// 3. completedFuture: already completed with a value. It runs nothing.
// Useful for cached values and for tests.
CompletableFuture<Catalog> f4 =
CompletableFuture.completedFuture(cachedCatalog);
// 4. failedFuture (Java 9): already completed with an exception.
CompletableFuture<Catalog> f5 =
CompletableFuture.failedFuture(new BiblioTechException("catalogue unavailable"));
// 5. Empty, to complete manually later (section 13).
CompletableFuture<Catalog> f6 = new CompletableFuture<>();completedFuture is more useful than it looks: it lets a method that sometimes has the answer immediately and sometimes does not always return the same type.
/**
* ALWAYS returns a CompletableFuture, whether the data is in the
* cache (immediate answer) or has to be computed (asynchronous).
* The caller does not have to tell the two cases apart.
*/
public CompletableFuture<Card> getCard(String isbn) {
Card cached = cache.get(isbn);
if (cached != null) {
return CompletableFuture.completedFuture(cached); // already ready
}
return CompletableFuture.supplyAsync(() -> buildCard(isbn), ioPool);
}
- The default executor and why you should pass your own
If you pass no Executor, supplyAsync and the ...Async variants use ForkJoinPool.commonPool(): the JVM's common pool you saw in 08-05, with cores - 1 threads.
That is fine for short computation tasks, and a serious problem for everything else:
// DANGEROUS: I/O on the common pool.
// The common pool has (cores - 1) threads and the whole JVM SHARES it:
// parallel streams (10-04), other libraries and the rest of your
// code. With 7 threads and 10 slow file reads, the pool becomes
// useless for everybody.
CompletableFuture.supplyAsync(() -> Files.readAllLines(hugePath));
// CORRECT: your own executor for I/O, sized according to 08-05.
private static final ExecutorService IO_POOL = new ThreadPoolExecutor(
16, 16, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(200),
r -> new Thread(r, "bibliotech-async-io-" + counter.getAndIncrement()),
new ThreadPoolExecutor.CallerRunsPolicy());
CompletableFuture.supplyAsync(() -> Files.readAllLines(hugePath), IO_POOL);A surprising detail worth knowing: if the machine has a single core, commonPool() has parallelism 0 and runs the tasks on the submitting thread. Your "asynchronous" code becomes synchronous with no warning, and you only discover it in a container with one core assigned.
| Executor | Threads | When to use it |
|---|---|---|
ForkJoinPool.commonPool() (default) |
cores - 1 |
Short, non-blocking computation |
| Your own compute pool | cores |
Isolated intensive computation |
| Your own I/O pool | Many more | Files, waits: always your own |
newVirtualThreadPerTaskExecutor() (Java 21) |
One virtual per task | Massive I/O (10-06) |
The rule, with no qualification: for tasks that block, always pass your own Executor. And with decent thread names, which is the 08-02 and 08-05 tip applied here.
- Transformation:
thenApply, thenAccept, thenRun
thenApply, thenAccept, thenRunThree ways of chaining depending on what the stage receives and returns:
| Method | Receives | Returns | Result |
|---|---|---|---|
thenApply(Function) |
The previous value | A new value | CompletableFuture<U> |
thenAccept(Consumer) |
The previous value | Nothing | CompletableFuture<Void> |
thenRun(Runnable) |
Nothing | Nothing | CompletableFuture<Void> |
import java.util.concurrent.CompletableFuture;
CompletableFuture<Void> chain =
CompletableFuture
// 1. Produces a Catalog
.supplyAsync(() -> loadCatalog(), IO_POOL)
// 2. thenApply: Catalog -> Double. Transforms.
.thenApply(catalog -> calculateTotalFines(catalog))
// 3. thenApply: Double -> String. Another transformation.
.thenApply(total -> String.format("Fines outstanding: %.2f EUR", total))
// 4. thenAccept: consumes the String, produces nothing.
.thenAccept(text -> System.out.println(text))
// 5. thenRun: receives and returns nothing. For final effects.
.thenRun(() -> LOG.info("Fines report completed"));
System.out.println("[main] chain declared, carrying on working");These are exactly the functional types of 04-06: Function<T,R>, Consumer<T> and Runnable. The whole CompletableFuture API is built on them, so if you mastered that lesson, this is its natural application.
An important note about ordering: declaring the chain does not run it. supplyAsync launches the first stage immediately; the rest are registered and fire when their turn comes. main carries on without waiting.
thenApply versus thenCompose
thenApply versus thenComposeThis is the most important distinction in the lesson and the number-one source of confusion.
thenApply is used when the function returns an ordinary value.
thenCompose is used when the function returns another CompletableFuture.
// A method returning an ordinary value:
Double calculateFines(Catalog c) { ... }
// A method returning a CompletableFuture (because it is asynchronous):
CompletableFuture<Double> calculateFinesAsync(Catalog c) { ... }If you use thenApply with the second, the type nests:
// THE MISTAKE: thenApply with a function returning a CompletableFuture.
CompletableFuture<CompletableFuture<Double>> nested =
CompletableFuture.supplyAsync(() -> loadCatalog())
.thenApply(c -> calculateFinesAsync(c));
// Now, to reach the Double, you have to unwrap TWICE:
Double d = nested.get().get(); // horrible, and it blocks twice
// THE SOLUTION: thenCompose FLATTENS the nesting.
CompletableFuture<Double> flat =
CompletableFuture.supplyAsync(() -> loadCatalog())
.thenCompose(c -> calculateFinesAsync(c));
Double d2 = flat.join(); // a single levelA mnemonic rule:
| If the function returns… | Use | Analogy with collections |
|---|---|---|
A value U |
thenApply |
map |
A CompletableFuture<U> |
thenCompose |
flatMap |
A complete example with both, in BiblioTech:
package com.nexussoftware.bibliotech.service;
import java.util.concurrent.CompletableFuture;
public class AsyncLookup {
/** Synchronous: returns the value directly. */
private Material findMaterial(String isbn) { ... }
/** Asynchronous: returns a CompletableFuture. */
private CompletableFuture<Card> buildCardAsync(Material m) { ... }
/** Synchronous: a cheap transformation. */
private String format(Card c) { ... }
/**
* A chain alternating synchronous and asynchronous operations.
* Notice that thenCompose appears exactly where the
* function returns a CompletableFuture.
*/
public CompletableFuture<String> formattedCard(String isbn) {
return CompletableFuture
.supplyAsync(() -> findMaterial(isbn), IO_POOL) // -> Material
.thenCompose(this::buildCardAsync) // -> Card (async)
.thenApply(this::format); // -> String (sync)
}
}How to spot the mistake in your own code: if you see a CompletableFuture<CompletableFuture<...>> type in a compiler message or in the IDE, you have used thenApply where thenCompose belonged. It is a compile error when you declare the type, and it goes unnoticed if you use var.
- The
...Async variants and which thread runs each stage
...Async variants and which thread runs each stageAlmost every method has three forms:
.thenApply(f) // 1. no suffix
.thenApplyAsync(f) // 2. with suffix, default executor
.thenApplyAsync(f, executor) // 3. with suffix and explicit executorThe difference is which thread runs the stage:
| Form | Thread that runs the stage |
|---|---|
thenApply(f) |
The thread that completed the previous stage, or the calling thread if it was already complete |
thenApplyAsync(f) |
A thread from ForkJoinPool.commonPool() |
thenApplyAsync(f, ex) |
A thread from ex |
The first row hides an important subtlety: with the suffix-free form, you do not know for certain which thread will run the stage. If the previous stage had already finished when you register the next one, the registering thread runs it —which can be main—.
public class WhichThreadRuns {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2,
r -> new Thread(r, "bibliotech-pool"));
System.out.println("main runs on: " + Thread.currentThread().getName());
CompletableFuture<String> f = CompletableFuture
.supplyAsync(() -> {
trace("supplyAsync");
return "catalog";
}, pool)
.thenApply(v -> {
trace("thenApply"); // the pool's thread
return v + "-processed";
})
.thenApplyAsync(v -> {
trace("thenApplyAsync"); // commonPool
return v + "-async";
})
.thenApplyAsync(v -> {
trace("thenApplyAsync(pool)"); // the pool we give it
return v + "-own";
}, pool);
System.out.println("result: " + f.join());
pool.shutdown();
}
static void trace(String stage) {
System.out.printf(" %-24s -> %s%n", stage, Thread.currentThread().getName());
}
}Output:
main runs on: main
supplyAsync -> bibliotech-pool
thenApply -> bibliotech-pool
thenApplyAsync -> ForkJoinPool.commonPool-worker-1
thenApplyAsync(pool) -> bibliotech-pool
result: catalog-processed-async-ownHow to decide:
- Cheap transformations (formatting, mapping, summing):
thenApplywith no suffix. It avoids an unnecessary thread switch. - Expensive or blocking work:
thenApplyAsyncwith your executor. Otherwise you occupy the thread that completed the previous stage —which may be a common-pool thread, or evenmain—.
// WRONG: blocking in a suffix-free stage occupies the previous thread,
// which could be a common-pool thread or main itself.
.thenApply(catalog -> writeToDisk(catalog)) // 500 ms blocking
// RIGHT: the heavy work goes to a dedicated pool.
.thenApplyAsync(catalog -> writeToDisk(catalog), IO_POOL)
- Combination:
thenCombine
thenCombinethenCombine joins the results of two independent futures that run in parallel.
package com.nexussoftware.bibliotech.service;
import java.util.concurrent.CompletableFuture;
public class AsyncSummary {
/**
* The two queries are independent and are launched at the same time.
* The total time is that of the SLOWEST, not the sum.
*/
public CompletableFuture<String> fullSummary(String isbn) {
CompletableFuture<Material> material =
CompletableFuture.supplyAsync(() -> catalog.find(isbn), IO_POOL);
CompletableFuture<Integer> loans =
CompletableFuture.supplyAsync(() -> registry.countLoans(isbn), IO_POOL);
// thenCombine waits for BOTH and applies the BiFunction (04-06).
return material.thenCombine(loans, (m, n) ->
String.format("%s (%s) - %d historical loans",
m.title(), m.isbn(), n));
}
/** Three or more: the thenCombines are chained. */
public CompletableFuture<FullReport> fullReport(String isbn) {
CompletableFuture<Material> material =
CompletableFuture.supplyAsync(() -> catalog.find(isbn), IO_POOL);
CompletableFuture<Integer> loans =
CompletableFuture.supplyAsync(() -> registry.countLoans(isbn), IO_POOL);
CompletableFuture<Double> fines =
CompletableFuture.supplyAsync(() -> calculator.finesOf(isbn), IO_POOL);
return material
.thenCombine(loans, MaterialLoansPartial::new)
.thenCombine(fines, (partial, f) ->
new FullReport(partial.material(), partial.loans(), f));
}
private record MaterialLoansPartial(Material material, int loans) { }
}The key point: the three queries are launched at once. If each takes 300 ms, the total is ~300 ms, not 900. It is the difference between real concurrency and a sequence in disguise.
thenCombine's less-used cousins:
| Method | What it does |
|---|---|
thenCombine(other, BiFunction) |
Waits for both and combines the results |
thenAcceptBoth(other, BiConsumer) |
Waits for both, consumes, returns nothing |
runAfterBoth(other, Runnable) |
Waits for both, ignores the values |
applyToEither(other, Function) |
The first to finish; applies the function |
acceptEither(other, Consumer) |
The first to finish; consumes it |
runAfterEither(other, Runnable) |
The first to finish |
allOf and anyOf
allOf and anyOfFor N futures instead of two.
allOf(cf1, cf2, ...) returns a CompletableFuture<Void> that completes when they all finish. It returns Void, so the results have to be collected separately.
package com.nexussoftware.bibliotech.service;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
public class AsyncNotices {
/**
* Sends every notice in parallel and waits for them ALL to finish.
*
* The collection pattern is always the same:
* 1. Create the list of futures.
* 2. allOf(...) over the array.
* 3. thenApply walking the futures and calling join()
* —safe, because allOf guarantees they are already complete—.
*/
public CompletableFuture<List<NoticeResult>> sendAll(List<Loan> overdue) {
List<CompletableFuture<NoticeResult>> futures = new ArrayList<>();
for (Loan l : overdue) {
futures.add(CompletableFuture.supplyAsync(() -> sendNotice(l), IO_POOL));
}
// allOf takes an array, not a list.
CompletableFuture<Void> all =
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
return all.thenApply(v -> {
List<NoticeResult> results = new ArrayList<>(futures.size());
for (CompletableFuture<NoticeResult> f : futures) {
// join() here does NOT block: allOf has already guaranteed
// that they are all complete.
results.add(f.join());
}
return results;
});
}
/**
* FAULT-TOLERANT variant: one failed notice does not take down the set.
*
* CAREFUL: if one future fails, the allOf fails too and its join()
* would rethrow the exception. The solution is to shield EACH future with
* its own exceptionally BEFORE aggregating them.
*/
public CompletableFuture<List<NoticeResult>> sendAllTolerant(
List<Loan> overdue) {
List<CompletableFuture<NoticeResult>> futures = new ArrayList<>();
for (Loan l : overdue) {
futures.add(CompletableFuture
.supplyAsync(() -> sendNotice(l), IO_POOL)
.exceptionally(e -> NoticeResult.failure(l, e.getMessage())));
}
return CompletableFuture
.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(v -> {
List<NoticeResult> r = new ArrayList<>();
for (CompletableFuture<NoticeResult> f : futures) r.add(f.join());
return r;
});
}
}anyOf(cf1, cf2, ...) completes with the result of the first to finish, for better or worse. It returns CompletableFuture<Object> —an API limitation, because the futures can be of different types—.
// Three sources for the same catalogue; the first will do.
CompletableFuture<Catalog> cache = readFromCache();
CompletableFuture<Catalog> file = readFromMainFile();
CompletableFuture<Catalog> backup = readFromBackup();
CompletableFuture<Object> first =
CompletableFuture.anyOf(cache, file, backup);
Catalog c = (Catalog) first.join(); // a cast is neededBeware of anyOf: it completes with the first to finish even if it finishes with an exception. If you want the first to finish successfully, you have to shield each one with exceptionally or use 08-05's invokeAny.
allOf |
anyOf |
|
|---|---|---|
| Completes when | All finish | The first finishes |
| Result type | CompletableFuture<Void> |
CompletableFuture<Object> |
| If one fails | The set fails | It may complete with that failure |
| Cancels the rest | No | No (they keep running) |
| Use | Batch work | Redundant sources |
- Errors:
exceptionally, handle, whenComplete
exceptionally, handle, whenCompleteThree methods with different roles:
| Method | Runs | Receives | Can change the result |
|---|---|---|---|
exceptionally(Function) |
Only on error | The exception | Yes: gives a fallback value |
handle(BiFunction) |
Always | Value and exception (one will be null) |
Yes |
whenComplete(BiConsumer) |
Always | Value and exception | No: it only observes |
package com.nexussoftware.bibliotech.service;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
import java.util.logging.Logger;
public class AsyncErrorHandling {
private static final Logger LOG =
Logger.getLogger(AsyncErrorHandling.class.getName());
/** 1. exceptionally: a fallback value if something fails. */
public CompletableFuture<Catalog> loadWithFallback() {
return CompletableFuture
.supplyAsync(() -> loadFromFile(), IO_POOL)
.exceptionally(error -> {
// 'error' is a CompletionException WRAPPING the cause.
LOG.log(Level.WARNING, "Load failed; using the fallback",
error.getCause());
return Catalog.empty(); // the chain CONTINUES
});
}
/** 2. handle: it always runs, and unifies the two paths. */
public CompletableFuture<String> reportWithHandle() {
return CompletableFuture
.supplyAsync(() -> generateReport(), IO_POOL)
.handle((result, error) -> {
// Exactly one of the two is null.
if (error != null) {
LOG.log(Level.SEVERE, "Report failed", error);
return "REPORT UNAVAILABLE: " + causeOf(error).getMessage();
}
return "REPORT OK: " + result;
});
}
/** 3. whenComplete: observes without altering. Ideal for traces and metrics. */
public CompletableFuture<Catalog> loadWithTrace() {
long start = System.nanoTime();
return CompletableFuture
.supplyAsync(() -> loadFromFile(), IO_POOL)
.whenComplete((catalog, error) -> {
long ms = (System.nanoTime() - start) / 1_000_000;
if (error != null) {
LOG.log(Level.WARNING, "Load failed in " + ms + " ms", error);
} else {
LOG.log(Level.INFO, "Catalogue loaded in {0} ms ({1} materials)",
new Object[] { ms, catalog.size() });
}
// Returning something here would NOT change the result:
// whenComplete only OBSERVES. The error keeps propagating.
});
}
/** Utility: unwrap the real cause. */
static Throwable causeOf(Throwable t) {
return (t instanceof java.util.concurrent.CompletionException
|| t instanceof java.util.concurrent.ExecutionException)
&& t.getCause() != null ? t.getCause() : t;
}
}The confusing detail about whenComplete: it alters nothing. If the stage failed, the resulting CompletableFuture still fails even though your BiConsumer calmly logged the error. It is an observer, not a handler. To handle, use handle or exceptionally.
A common and recommendable combination:
CompletableFuture<Path> report = CompletableFuture
.supplyAsync(() -> loadCatalog(), IO_POOL)
.thenApplyAsync(this::calculateFines, COMPUTE_POOL)
.thenApplyAsync(this::export, IO_POOL)
.whenComplete((path, error) -> recordMetric(path, error)) // observe
.exceptionally(error -> { // handle
LOG.log(Level.SEVERE, "Report chain failed", error);
return Path.of("reports/error.txt");
});
- How an exception travels through the chain
When a stage throws an exception, every following stage is skipped until a handler is found. It is just like a throw crossing several methods.
public class ErrorPropagation {
public static void main(String[] args) {
CompletableFuture<String> chain = CompletableFuture
.supplyAsync(() -> {
System.out.println(" stage 1: OK");
return "catalog";
})
.thenApply(v -> {
System.out.println(" stage 2: throwing an exception");
throw new IllegalStateException("corrupt catalogue");
})
.thenApply(v -> {
System.out.println(" stage 3: DOES NOT RUN");
return v + "-processed";
})
.thenApply(v -> {
System.out.println(" stage 4: NOR DOES THIS");
return v.toUpperCase();
})
.exceptionally(error -> {
System.out.println(" handler: caught " + error.getClass().getSimpleName());
System.out.println(" real cause: "
+ error.getCause().getClass().getSimpleName()
+ ": " + error.getCause().getMessage());
return "FALLBACK-VALUE";
})
.thenApply(v -> {
System.out.println(" stage 5: this DOES run, with the fallback");
return v.toLowerCase();
});
System.out.println("result: " + chain.join());
}
}Output:
stage 1: OK
stage 2: throwing an exception
handler: caught CompletionException
real cause: IllegalStateException: corrupt catalogue
stage 5: this DOES run, with the fallback
result: fallback-valueThree things this output teaches:
1. Stages 3 and 4 are skipped entirely. The exception short-circuits the chain to the first handler.
2. The exception arrives wrapped in CompletionException. It is not your IllegalStateException directly: it is in getCause(). It is the same thing ExecutionException did with Future in 08-05, and for the same reason: the original exception may be checked or not, and it has to be carried through an API that does not declare it.
3. After exceptionally, the chain carries on as normal. Stage 5 runs with the fallback value. That is exactly the point of exceptionally: recover and continue.
The classic mistake: chaining after exceptionally without noticing.
// TRAP: it looks as if the exceptionally protects the whole chain.
CompletableFuture<Path> f = CompletableFuture
.supplyAsync(() -> loadCatalog())
.exceptionally(e -> Catalog.empty()) // protects what is ABOVE
.thenApply(c -> calculateFines(c)) // <-- UNprotected
.thenApply(m -> export(m)); // <-- UNprotected
// If calculateFines or export fail, the exception comes out of join()
// and nobody handles it. The exceptionally was too far up.
// CORRECT: the handler at the END of the chain.
CompletableFuture<Path> g = CompletableFuture
.supplyAsync(() -> loadCatalog())
.thenApply(c -> calculateFines(c))
.thenApply(m -> export(m))
.exceptionally(e -> { // protects EVERYTHING above
LOG.log(Level.SEVERE, "Chain failed", e);
return Path.of("reports/error.txt");
});A handler only covers what is above it. If you need intermediate recovery and final protection, use two.
The gravest mistake of all: handling nothing.
// If this chain fails, NOTHING VISIBLE HAPPENS. There is no trace,
// no log, no exception. The failure simply disappears.
CompletableFuture.supplyAsync(() -> loadCatalog())
.thenAccept(c -> globalCatalog = c);It is the same problem as submit without get() in 08-05, and with the same consequences. Every chain must end in exceptionally, handle or whenComplete.
- Deadlines:
orTimeout and completeOnTimeout
orTimeout and completeOnTimeoutJava 9 added two methods that save you having to set up a timer by hand.
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
// orTimeout: if it does not complete in time, it FAILS with TimeoutException.
CompletableFuture<Catalog> withDeadline = CompletableFuture
.supplyAsync(() -> loadCatalog(), IO_POOL)
.orTimeout(5, TimeUnit.SECONDS)
.exceptionally(error -> {
if (causeOf(error) instanceof java.util.concurrent.TimeoutException) {
LOG.warning("The load exceeded 5 seconds");
}
return Catalog.empty();
});
// completeOnTimeout: if it does not complete in time, it completes
// with the DEFAULT VALUE. It does not fail.
CompletableFuture<Catalog> withDefault = CompletableFuture
.supplyAsync(() -> loadCatalog(), IO_POOL)
.completeOnTimeout(Catalog.empty(), 5, TimeUnit.SECONDS);orTimeout(t, u) |
completeOnTimeout(v, t, u) |
|
|---|---|---|
| On the deadline expiring | Fails with TimeoutException |
Completes with v |
| Does it have to be handled? | Yes | No |
| Does it cancel the underlying task? | No | No |
| Use when | You want to know about the delay | You have an acceptable degraded value |
The essential warning: neither of them cancels the task that keeps running. The CompletableFuture completes —with an error or with the default value—, but the thread that was loading the catalogue keeps working and consuming resources until it finishes on its own. It is the same as Future.get()'s TimeoutException in 08-05.
- Completing manually: adapting a callback API
Here the "C" in CompletableFuture pays off. An empty CompletableFuture can be completed from any thread, which lets you wrap an old callback-based API:
package com.nexussoftware.bibliotech.persistence;
import java.util.concurrent.CompletableFuture;
public class CallbackAdapter {
/**
* An old callback-based API. It is awkward to compose:
* nesting three of these produces the "pyramid of doom".
*/
interface CallbackReader {
void readAsync(String isbn, Callback<Material> callback);
}
interface Callback<T> {
void onComplete(T result);
void onFailure(Throwable error);
}
private final CallbackReader reader;
public CallbackAdapter(CallbackReader reader) { this.reader = reader; }
/**
* Turns the callback API into a composable CompletableFuture.
* It is the standard pattern for modernising legacy code without touching it.
*/
public CompletableFuture<Material> read(String isbn) {
// 1. An empty future, with no task attached.
CompletableFuture<Material> future = new CompletableFuture<>();
// 2. The operation is launched with a callback that completes it.
reader.readAsync(isbn, new Callback<Material>() {
@Override
public void onComplete(Material m) {
future.complete(m); // success
}
@Override
public void onFailure(Throwable error) {
future.completeExceptionally(error); // failure
}
});
// 3. It returns immediately, without waiting for the callback.
return future;
}
/**
* And now the old API is composable like any other:
* three reads in parallel and combined, with no nesting.
*/
public CompletableFuture<String> compareThree(String i1, String i2, String i3) {
return read(i1)
.thenCombine(read(i2), (a, b) -> a.title() + " / " + b.title())
.thenCombine(read(i3), (pair, c) -> pair + " / " + c.title());
}
}Manual completion methods:
| Method | What it does |
|---|---|
complete(v) |
Completes with v; returns false if it was already complete |
completeExceptionally(t) |
Completes with the exception t |
completeAsync(supplier, ex) |
Completes by running the Supplier on ex (Java 9) |
getNow(valueIfNotReady) |
Returns the value without blocking, or the default |
isCompletedExceptionally() |
Did it finish with an error? |
- Cancellation and its limits
CompletableFuture.cancel(boolean) exists because it implements Future, but it works differently from what you expect:
CompletableFuture<Catalog> f =
CompletableFuture.supplyAsync(() -> loadCatalogSlowly(), IO_POOL);
TimeUnit.SECONDS.sleep(1);
boolean cancelled = f.cancel(true); // the 'true' is IGNORED
System.out.println("cancel(): " + cancelled); // true
System.out.println("isCancelled(): " + f.isCancelled()); // true
// BUT: the IO_POOL thread carries on running loadCatalogSlowly()
// to the end. The mayInterruptIfRunning parameter DOES NOTHING.What cancel does: it completes the CompletableFuture with a CancellationException, so the following stages do not run and join() throws.
What it does NOT do: interrupt the thread running the task. The mayInterruptIfRunning parameter is ignored, and the documentation says so. The task carries on to its own end.
If you need real cancellation, it has to be implemented with the 08-02 protocol:
package com.nexussoftware.bibliotech.persistence;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* REAL cancellation of an asynchronous task: a shared flag
* the task checks. It is the cooperative interruption of 08-02
* adapted to CompletableFuture, which cannot interrupt on its own.
*/
public class CancellableImport {
private final AtomicBoolean cancelled = new AtomicBoolean(false);
private final CompletableFuture<Report> future;
public CancellableImport(Path file) {
this.future = CompletableFuture.supplyAsync(() -> importFile(file), IO_POOL);
}
private Report importFile(Path file) {
int processed = 0;
for (String line : readLines(file)) {
// An explicit CANCELLATION POINT.
if (cancelled.get() || Thread.currentThread().isInterrupted()) {
throw new CancellationException(
"Import cancelled after " + processed + " lines");
}
process(line);
processed++;
}
return new Report(processed);
}
/** Cancellation that DOES stop the work. */
public void cancel() {
cancelled.set(true); // the task will see it and abort
future.cancel(false); // and the future completes right away
}
public CompletableFuture<Report> future() { return future; }
}
- Good practice and traps
Trap 1: blocking inside a stage.
// TERRIBLE: join() inside a stage blocks a pool thread.
// With enough chains like this, the pool runs dry and everything stops.
.thenApply(catalog -> {
Double fines = calculateFinesAsync(catalog).join(); // BLOCKS
return fines;
})
// CORRECT: thenCompose flattens without blocking anybody.
.thenCompose(catalog -> calculateFinesAsync(catalog))Trap 2: using the common pool for I/O. Already seen in section 4: cores - 1 threads shared by the whole JVM. Always pass your executor for blocking work.
Trap 3: join() versus get(). Both block; the difference is in the exceptions:
// get(): throws ExecutionException and InterruptedException, both CHECKED.
try {
Catalog c = future.get();
} catch (ExecutionException | InterruptedException e) { ... }
// join(): throws CompletionException, UNchecked. More convenient
// inside lambdas, where a checked exception does not compile.
Catalog c = future.join();Inside a lambda, join() is almost mandatory because get() would not compile. Outside, get(timeout) is preferable for the deadline.
Trap 4: not knowing which thread runs each stage. Review section 7. A stage with no Async suffix can end up running on main.
Trap 5: unreadable chains. Ten chained stages are as bad to read as ten nested ifs. Extract methods:
// WRONG: a single twenty-line expression.
// RIGHT: every step with a name.
public CompletableFuture<Path> generateMonthlyReport() {
return loadCatalog()
.thenCompose(this::enrichWithLoans)
.thenApplyAsync(this::calculateFines, COMPUTE_POOL)
.thenApplyAsync(this::format, COMPUTE_POOL)
.thenApplyAsync(this::writeFile, IO_POOL)
.orTimeout(60, TimeUnit.SECONDS)
.whenComplete(this::recordMetrics)
.exceptionally(this::errorReport);
}Trap 6: forgetting the final handler. An unhandled failure disappears without a trace.
The six rules, summarised:
- Your own executor for anything that blocks.
- Never block inside a stage:
thenCompose, notjoin(). thenComposewhen the function returns a future;thenApplywhen it returns a value.- Always a final handler:
exceptionallyorhandleat the end of the chain. ...Asyncwith your executor for expensive work; no suffix for cheap transformations.- Extract named methods: a chain should read like a list of steps.
- BiblioTech: the complete asynchronous chain
The module's closing piece. A real business operation —generating the monthly fines report and exporting it— without blocking the menu at any point.
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.domain.*;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Asynchronous generation of BiblioTech's monthly report.
*
* The complete chain:
* 1. Load the catalogue (I/O) -- in parallel with 2
* 2. Load the active loans (I/O) -- in parallel with 1
* 3. Combine both
* 4. Calculate the fines (CPU)
* 5. Format the report (CPU)
* 6. Write the file (I/O)
*
* NO thread blocks waiting: the menu carries on serving the user
* throughout the whole operation.
*/
public class AsyncReportGenerator implements AutoCloseable {
private static final Logger LOG =
Logger.getLogger(AsyncReportGenerator.class.getName());
/** I/O pool: many threads because it is nearly all waiting (08-01). */
private final ExecutorService ioPool;
/** Compute pool: one thread per core. */
private final ExecutorService computePool;
private final ConcurrentCatalog catalog;
private final SafeLoanRegistry registry;
private final FineCalculator calculator;
public AsyncReportGenerator(ConcurrentCatalog catalog,
SafeLoanRegistry registry,
FineCalculator calculator) {
this.catalog = catalog;
this.registry = registry;
this.calculator = calculator;
AtomicInteger nIo = new AtomicInteger(1);
this.ioPool = new ThreadPoolExecutor(
16, 16, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(200),
r -> new Thread(r, "bibliotech-async-io-" + nIo.getAndIncrement()),
new ThreadPoolExecutor.CallerRunsPolicy());
AtomicInteger nCpu = new AtomicInteger(1);
this.computePool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors(),
r -> new Thread(r, "bibliotech-async-cpu-" + nCpu.getAndIncrement()));
}
// ---------- STAGES ----------
private CompletableFuture<List<Material>> loadMaterials() {
return CompletableFuture.supplyAsync(() -> {
trace("loading materials");
pause(400); // simulated I/O
return catalog.byType(MaterialType.BOOK);
}, ioPool);
}
private CompletableFuture<List<Loan>> loadActiveLoans() {
return CompletableFuture.supplyAsync(() -> {
trace("loading loans");
pause(500); // simulated I/O, IN PARALLEL
return registry.allActive();
}, ioPool);
}
/** A CPU stage: it is sent to the compute pool explicitly. */
private ReportData compute(List<Material> materials, List<Loan> loans) {
trace("calculating fines");
double total = 0;
int overdue = 0;
for (Loan l : loans) {
double f = calculator.calculate(l);
if (f > 0) { total += f; overdue++; }
}
return new ReportData(materials.size(), loans.size(), overdue, total);
}
private String format(ReportData d) {
trace("formatting");
return """
===========================================
BiblioTech - Monthly fines report
Nexus Software
===========================================
Materials in catalogue : %d
Active loans : %d
Overdue loans : %d
Accumulated fines : %.2f EUR
===========================================
""".formatted(d.materials(), d.loans(), d.overdue(), d.fines());
}
private Path write(String content) {
trace("writing the file");
pause(300); // simulated I/O
Path target = Path.of("reports", "monthly-fines.txt");
// In the real code: AtomicWrite.write(target, content) from 07-06
return target;
}
// ---------- THE CHAIN ----------
/**
* Declares the whole chain and RETURNS IMMEDIATELY.
* The calling thread waits for nothing.
*/
public CompletableFuture<Path> generate() {
long start = System.nanoTime();
// 1 and 2 start AT THE SAME TIME: they are not chained, they are combined.
CompletableFuture<List<Material>> materials = loadMaterials();
CompletableFuture<List<Loan>> loans = loadActiveLoans();
return materials
// 3. Wait for both and combine them. The work is sent to the
// compute pool with thenCombineAsync, so as not to occupy
// an I/O thread with CPU work.
.thenCombineAsync(loans, this::compute, computePool)
// 4. Format: CPU, same pool.
.thenApplyAsync(this::format, computePool)
// 5. Write: I/O, I/O pool.
.thenApplyAsync(this::write, ioPool)
// 6. Global deadline.
.orTimeout(30, TimeUnit.SECONDS)
// 7. Observe without altering: metrics.
.whenComplete((path, error) -> {
long ms = (System.nanoTime() - start) / 1_000_000;
if (error == null) {
LOG.log(Level.INFO, "Report generated in {0} ms: {1}",
new Object[] { ms, path });
} else {
LOG.log(Level.SEVERE, "Report failed after " + ms + " ms", error);
}
})
// 8. Handle: at the END, to cover the whole chain.
.exceptionally(error -> {
Throwable cause = causeOf(error);
if (cause instanceof TimeoutException) {
LOG.warning("The report exceeded 30 seconds");
}
return Path.of("reports", "report-unavailable.txt");
});
}
// ---------- HELPERS ----------
record ReportData(int materials, int loans, int overdue, double fines) { }
static Throwable causeOf(Throwable t) {
return (t instanceof CompletionException || t instanceof ExecutionException)
&& t.getCause() != null ? t.getCause() : t;
}
private static void trace(String stage) {
System.out.printf(" [%s] %s%n", Thread.currentThread().getName(), stage);
}
private static void pause(long ms) {
try { TimeUnit.MILLISECONDS.sleep(ms); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
@Override
public void close() {
for (ExecutorService ex : List.of(computePool, ioPool)) {
ex.shutdown();
try {
if (!ex.awaitTermination(15, TimeUnit.SECONDS)) ex.shutdownNow();
} catch (InterruptedException e) {
ex.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
}The menu that stays alive:
package com.nexussoftware.bibliotech.presentation;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
public class MenuWithAsyncReport {
public static void main(String[] args) throws Exception {
try (AsyncReportGenerator generator = new AsyncReportGenerator(
catalog, registry, calculator)) {
System.out.println("=== BiblioTech - Nexus Software ===");
System.out.println("Launching the monthly report in the background...\n");
// The whole chain is declared and returns instantly.
CompletableFuture<Path> report = generator.generate();
// We register what to do when it finishes. We do NOT wait.
report.thenAccept(path ->
System.out.println("\n>>> Report ready at: " + path));
// The menu carries on serving the user.
for (int i = 1; i <= 8; i++) {
System.out.printf(" [menu] option %d served (the menu is NOT blocked)%n", i);
TimeUnit.MILLISECONDS.sleep(200);
}
// Only at the end, if the result is genuinely needed, do we wait.
Path path = report.get(30, TimeUnit.SECONDS);
System.out.println("\n[main] confirmed: " + path);
}
}
}Output:
=== BiblioTech - Nexus Software ===
Launching the monthly report in the background...
[menu] option 1 served (the menu is NOT blocked)
[bibliotech-async-io-1] loading materials
[bibliotech-async-io-2] loading loans
[menu] option 2 served (the menu is NOT blocked)
[menu] option 3 served (the menu is NOT blocked)
[bibliotech-async-cpu-1] calculating fines
[bibliotech-async-cpu-1] formatting
[menu] option 4 served (the menu is NOT blocked)
[bibliotech-async-io-3] writing the file
>>> Report ready at: reports/monthly-fines.txt
[menu] option 5 served (the menu is NOT blocked)
[menu] option 6 served (the menu is NOT blocked)
[menu] option 7 served (the menu is NOT blocked)
[menu] option 8 served (the menu is NOT blocked)
INFO: Report generated in 812 ms: reports/monthly-fines.txt
[main] confirmed: reports/monthly-fines.txtFour things this output demonstrates:
- The menu never stops. All eight options are served while the report is generated. It is case A of 08-01, solved definitively.
- The two loads happen in parallel, on
async-io-1andasync-io-2. The total time for that phase is that of the slowest (500 ms), not the sum (900 ms). - Each stage runs on the right pool: I/O on the
iothreads, computation on thecpuones. The bulkhead isolation of 08-05, applied inside a single chain. - The total is 812 ms, against the 1200 ms of the sequential version (400 + 500 + 300). And —the important part— during those 812 ms the main thread was not blocked for an instant.
- Comparison with the reactive model and virtual threads
CompletableFuture is not the last word in asynchrony in Java. It is worth placing it.
The reactive model (Reactive Streams, with implementations like Project Reactor and RxJava) generalises the idea to streams of many values instead of a single result. It adds two things CompletableFuture does not have: operators to transform whole streams, and backpressure, the mechanism by which a slow consumer tells the producer to ease off —the same idea as the bounded queues of 08-06, built into the model—. Java 9 incorporated the Flow.Publisher and Flow.Subscriber interfaces into the standard library, but without an implementation: it is a meeting point between libraries. Spring WebFlux, in 11-02, rests on this model.
Virtual threads (Java 21) attack the problem from the opposite side. Instead of making asynchronous code more composable, they make blocking code stop being expensive:
// With CompletableFuture: asynchronous, composable, and demanding to read.
CompletableFuture<Path> f = CompletableFuture
.supplyAsync(() -> loadCatalog(), ioPool)
.thenApplyAsync(this::calculateFines, cpuPool)
.thenApplyAsync(this::export, ioPool)
.exceptionally(this::fallback);
// With virtual threads: ordinary SEQUENTIAL code, with ordinary try/catch,
// blocking a virtual thread —whose cost is in nanoseconds—.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
Catalog c = loadCatalog(); // "blocks", but it is dirt cheap
double f2 = calculateFines(c);
return export(f2);
});
}The second reads like sequential code —with ordinary try/catch, legible stack traces and conventional debugging— and scales like the first, because blocking a virtual thread blocks no operating-system thread. It is a fundamental change in how concurrency will be written in Java, and it is explained in 10-06.
CompletableFuture |
Reactive | Virtual threads | |
|---|---|---|---|
| Values | One | Many (a stream) | One |
| Style | Composition of stages | Operators over streams | Ordinary sequential |
| Backpressure | No | Yes | Natural (real blocking) |
| Readability | Medium | Low at first | High |
| Debugging | Hard (split traces) | Very hard | Normal |
| Since | Java 8 | A library / Java 9 (Flow) |
Java 21 |
| Seen in | This lesson | 11-02 (WebFlux) | 10-06 |
When CompletableFuture is still the right tool: when you need to combine a few independent results, when you work with APIs that already return it —HttpClient.sendAsync in 09-06, much of Spring—, or when your Java is older than 21. It is a piece to know, not one to apply everywhere.
Common Mistakes and Tips
Mistake 1: using thenApply when the function returns a CompletableFuture. The result is CompletableFuture<CompletableFuture<T>> and you have to unwrap twice. Use thenCompose.
Mistake 2: blocking with join() or get() inside a stage. It occupies a pool thread waiting. With enough chains, the pool runs dry and everything stops. thenCompose instead.
Mistake 3: using the common pool for blocking tasks. cores - 1 threads shared by the whole JVM, parallel streams included. Pass your executor.
Mistake 4: not putting any error handler in. The failure disappears with no trace, no log and no exception. Every chain ends in exceptionally or handle.
Mistake 5: putting the exceptionally too far up. It only covers the stages before it. To protect the whole chain, it goes at the end.
Mistake 6: forgetting that the exception comes wrapped in CompletionException. The original is in getCause(). Write a causeOf(Throwable) utility and always use it.
Mistake 7: believing cancel(true) interrupts the task. The parameter is ignored. For real cancellation, a shared flag and check points (08-02).
Mistake 8: believing orTimeout cancels the underlying work. It does not: the future fails, the task carries on.
Mistake 9: using allOf with futures that can fail without shielding them. One failure makes the set fail. Put an exceptionally on each one before aggregating them.
Mistake 10: anyOf expecting the first success. It completes with the first to finish, even if it finishes with an exception.
Mistake 11: chains of fifteen stages in a single expression. Unreadable and impossible to debug. Extract named methods.
Tip 1: one of your own, named executors per type of work. One for I/O and one for computation, as in section 16. The thread names are appreciated at the first dump (08-03).
Tip 2: whenComplete for metrics, exceptionally to recover. The first observes without altering; the second changes the result. Chain them in that order.
Tip 3: join() inside lambdas, get(timeout) outside. join throws an unchecked exception, which is the only thing that compiles inside a Function.
Tip 4: a chain should read like a list of steps. If you cannot explain it out loud reading it top to bottom, extract methods.
Tip 5: completedFuture unifies the fast and slow paths. A method that sometimes answers from a cache and sometimes computes must always return the same type.
Tip 6: if your Java is 21+, consider whether you need this. For a sequential chain of blocking steps, a virtual thread gives the same performance with far simpler code (10-06). CompletableFuture is still better for combining independent results.
Exercises
Exercise 1: From Future to CompletableFuture
Take this operation written with Future and rewrite it with CompletableFuture with no intermediate blocking:
Future<Catalog> f1 = executor.submit(() -> loadCatalog());
Catalog c = f1.get();
Future<List<Loan>> f2 = executor.submit(() -> loadLoans(c));
List<Loan> p = f2.get();
Future<Double> f3 = executor.submit(() -> calculateFines(p));
double total = f3.get();
Future<Path> f4 = executor.submit(() -> export(total));
Path report = f4.get();The new version must use one I/O pool and one compute pool, apply thenCompose where appropriate (make loadLoans return a CompletableFuture), set a global 20-second deadline, log the total time with whenComplete and handle the error at the end by returning a fallback path. Measure and compare the time of both versions simulating 300 ms per operation.
Exercise 2: A complete card combining three sources
Write CardService with a method CompletableFuture<FullCard> fullCard(String isbn) combining three independent queries launched in parallel: the material's data (400 ms), the number of historical loans (600 ms) and the average rating (300 ms). Requirements:
- The three are launched at once; the total time must be ~600 ms, not 1300.
- Each individual query must have its own
exceptionallywith a degraded value, so that one failure does not stop the card being built. - The combination is done with chained
thenCombines. completeOnTimeoutwith a minimal card if the set takes more than 2 seconds.- A
maindemonstrating the correct case and the case where the ratings query fails.
Exercise 3: Asynchronous bulk sending with result collection
Rewrite the sending of the 200 notices from 08-05 with CompletableFuture. AsyncNoticeSending must:
- Create one
CompletableFuture<NoticeResult>per notice, with its own 16-thread I/O pool and decent names. - Shield each future with
exceptionallyso that an individual failure produces a failureNoticeResultinstead of breaking the set. - Aggregate with
allOfand collect every result with the pattern from section 9. - On the aggregated future, chain a
thenApplyproducing aSendSummary(total, sent, failed, milliseconds). - Apply
orTimeout(60, SECONDS)and a finalexceptionally. - While the chain runs,
mainmust print progress using aLongAddereach task increments on finishing — demonstrating that the main thread is not blocked.
Solutions
Solution to Exercise 1
package com.nexussoftware.bibliotech.service;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class ReportChain {
private final ExecutorService ioPool;
private final ExecutorService cpuPool;
public ReportChain() {
AtomicInteger a = new AtomicInteger(1);
this.ioPool = Executors.newFixedThreadPool(8,
r -> new Thread(r, "report-io-" + a.getAndIncrement()));
AtomicInteger b = new AtomicInteger(1);
this.cpuPool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors(),
r -> new Thread(r, "report-cpu-" + b.getAndIncrement()));
}
// --- Simulated operations ---
private Catalog loadCatalog() {
pause(300);
return new Catalog();
}
/**
* ASYNCHRONOUS: it returns a CompletableFuture, not a value.
* That is why it is chained with thenCompose and not thenApply.
*/
private CompletableFuture<List<Loan>> loadLoansAsync(Catalog c) {
return CompletableFuture.supplyAsync(() -> {
pause(300);
return List.<Loan>of();
}, ioPool);
}
private double calculateFines(List<Loan> p) {
pause(300);
return 137.50;
}
private Path export(double total) {
pause(300);
return Path.of("reports/fines.txt");
}
// --- BLOCKING VERSION (the original) ---
public Path futureVersion() throws Exception {
ExecutorService ex = Executors.newFixedThreadPool(4);
try {
Future<Catalog> f1 = ex.submit(this::loadCatalog);
Catalog c = f1.get(); // BLOCKS
Future<List<Loan>> f2 = ex.submit(() -> {
pause(300); return List.<Loan>of();
});
List<Loan> p = f2.get(); // BLOCKS
Future<Double> f3 = ex.submit(() -> calculateFines(p));
double total = f3.get(); // BLOCKS
Future<Path> f4 = ex.submit(() -> export(total));
return f4.get(); // BLOCKS
} finally {
ex.shutdown();
}
}
// --- ASYNCHRONOUS VERSION ---
public CompletableFuture<Path> completableFutureVersion() {
long start = System.nanoTime();
return CompletableFuture
// 1. I/O: the I/O pool
.supplyAsync(this::loadCatalog, ioPool)
// 2. thenCompose because loadLoansAsync returns
// a CompletableFuture. With thenApply we would get
// CompletableFuture<CompletableFuture<List<Loan>>>.
.thenCompose(this::loadLoansAsync)
// 3. CPU: the compute pool
.thenApplyAsync(this::calculateFines, cpuPool)
// 4. I/O: the I/O pool
.thenApplyAsync(this::export, ioPool)
// 5. Global deadline
.orTimeout(20, TimeUnit.SECONDS)
// 6. Observe without altering
.whenComplete((path, error) -> {
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.printf(" [chain] finished in %d ms (%s)%n",
ms, error == null ? "OK" : "ERROR");
})
// 7. Handle at the END: it covers every earlier stage
.exceptionally(error -> {
System.err.println(" [chain] failure: " + causeOf(error).getMessage());
return Path.of("reports/unavailable.txt");
});
}
static Throwable causeOf(Throwable t) {
return (t instanceof CompletionException || t instanceof ExecutionException)
&& t.getCause() != null ? t.getCause() : t;
}
static void pause(long ms) {
try { TimeUnit.MILLISECONDS.sleep(ms); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
public void shutdown() {
ioPool.shutdown();
cpuPool.shutdown();
}
public static void main(String[] args) throws Exception {
ReportChain rc = new ReportChain();
try {
System.out.println("=== FUTURE VERSION (blocking) ===");
long t1 = System.nanoTime();
Path p1 = rc.futureVersion();
long ms1 = (System.nanoTime() - t1) / 1_000_000;
System.out.println(" result: " + p1 + " (" + ms1 + " ms)");
System.out.println(" the main thread was blocked 4 times\n");
System.out.println("=== COMPLETABLEFUTURE VERSION ===");
long t2 = System.nanoTime();
CompletableFuture<Path> f = rc.completableFutureVersion();
long msDeclaration = (System.nanoTime() - t2) / 1_000_000;
System.out.println(" chain declared in " + msDeclaration + " ms");
System.out.println(" the main thread is still free; doing other things...");
for (int i = 1; i <= 5; i++) {
System.out.println(" main work " + i);
TimeUnit.MILLISECONDS.sleep(150);
}
Path p2 = f.get(20, TimeUnit.SECONDS);
long ms2 = (System.nanoTime() - t2) / 1_000_000;
System.out.println(" result: " + p2 + " (" + ms2 + " ms)");
} finally {
rc.shutdown();
}
}
}Output:
=== FUTURE VERSION (blocking) ===
result: reports/fines.txt (1214 ms)
the main thread was blocked 4 times
=== COMPLETABLEFUTURE VERSION ===
chain declared in 3 ms
the main thread is still free; doing other things...
main work 1
main work 2
main work 3
main work 4
main work 5
[chain] finished in 1208 ms (OK)
result: reports/fines.txt (1211 ms)The right comparison is not in the total time —both take ~1.2 s, because the steps are sequentially dependent—, but in the line "chain declared in 3 ms". The blocking version consumes 1214 ms of the main thread; the asynchronous one consumes 3 ms and gives control back. That thread could do five other things in the meantime. Asynchrony does not speed up what is sequentially dependent: it frees the orchestrating thread.
Solution to Exercise 2
package com.nexussoftware.bibliotech.service;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class CardService implements AutoCloseable {
record MaterialData(String title, String author, String isbn) {
static MaterialData unknown(String isbn) {
return new MaterialData("(title not available)", "(author unknown)", isbn);
}
}
record FullCard(MaterialData material, int loans, double rating) {
static FullCard minimal(String isbn) {
return new FullCard(MaterialData.unknown(isbn), -1, -1.0);
}
@Override public String toString() {
return String.format("%s / %s [%s] - %s loans - rating %s",
material.title(), material.author(), material.isbn(),
loans < 0 ? "?" : loans,
rating < 0 ? "?" : String.format("%.1f", rating));
}
}
private final ExecutorService pool;
private final boolean failRatings;
public CardService(boolean failRatings) {
this.failRatings = failRatings;
AtomicInteger n = new AtomicInteger(1);
this.pool = Executors.newFixedThreadPool(8,
r -> new Thread(r, "cards-" + n.getAndIncrement()));
}
// --- The three queries, each with its own fallback ---
private CompletableFuture<MaterialData> queryMaterial(String isbn) {
return CompletableFuture.supplyAsync(() -> {
pause(400);
return new MaterialData("Effective Java", "J. Bloch", isbn);
}, pool)
// An INDIVIDUAL fallback: if this query fails, the set
// carries on with a degraded value.
.exceptionally(e -> {
System.err.println(" [fallback] material not available: " + causeOf(e).getMessage());
return MaterialData.unknown(isbn);
});
}
private CompletableFuture<Integer> queryLoans(String isbn) {
return CompletableFuture.supplyAsync(() -> {
pause(600); // the slowest: it sets the total
return 47;
}, pool)
.exceptionally(e -> -1);
}
private CompletableFuture<Double> queryRating(String isbn) {
return CompletableFuture.supplyAsync(() -> {
pause(300);
if (failRatings) {
throw new IllegalStateException("ratings service down");
}
return 4.6;
}, pool)
.exceptionally(e -> {
System.err.println(" [fallback] rating not available: "
+ causeOf(e).getMessage());
return -1.0;
});
}
// --- The combination ---
public CompletableFuture<FullCard> fullCard(String isbn) {
// All THREE are launched at once: the assignment already starts the work.
CompletableFuture<MaterialData> material = queryMaterial(isbn);
CompletableFuture<Integer> loans = queryLoans(isbn);
CompletableFuture<Double> rating = queryRating(isbn);
return material
// Chained thenCombine: first material+loans...
.thenCombine(loans, (m, l) -> new Object[] { m, l })
// ...and then the pair with the rating.
.thenCombine(rating, (pair, r) -> new FullCard(
(MaterialData) pair[0], (Integer) pair[1], r))
// If the set takes too long, a minimal card instead of a failure.
.completeOnTimeout(FullCard.minimal(isbn), 2, TimeUnit.SECONDS);
}
static Throwable causeOf(Throwable t) {
return (t instanceof CompletionException || t instanceof ExecutionException)
&& t.getCause() != null ? t.getCause() : t;
}
static void pause(long ms) {
try { TimeUnit.MILLISECONDS.sleep(ms); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
@Override public void close() { pool.shutdown(); }
public static void main(String[] args) throws Exception {
System.out.println("=== NORMAL CASE ===");
try (CardService s = new CardService(false)) {
long t = System.nanoTime();
FullCard c = s.fullCard("978-0000000001").get();
System.out.println(" " + c);
System.out.printf(" time: %d ms (sequential would be 1300)%n",
(System.nanoTime() - t) / 1_000_000);
}
System.out.println("\n=== WITH THE RATINGS SERVICE DOWN ===");
try (CardService s = new CardService(true)) {
long t = System.nanoTime();
FullCard c = s.fullCard("978-0000000001").get();
System.out.println(" " + c);
System.out.printf(" time: %d ms%n", (System.nanoTime() - t) / 1_000_000);
System.out.println(" The card is built anyway: degradation, not failure (06-07).");
}
}
}Output:
=== NORMAL CASE ===
Effective Java / J. Bloch [978-0000000001] - 47 loans - rating 4.6
time: 612 ms (sequential would be 1300)
=== WITH THE RATINGS SERVICE DOWN ===
[fallback] rating not available: ratings service down
Effective Java / J. Bloch [978-0000000001] - 47 loans - rating ?
time: 608 ms
The card is built anyway: degradation, not failure (06-07).Two key results:
- 612 ms against 1300 ms sequentially. The total is that of the slowest query (600 ms) plus the coordination cost, because the three run in parallel.
- One source failing does not take down the card. Each query's individual
exceptionallydegrades it to a default value and the rest is built normally. It is the 06-07 policy —degrade when you can, abort only when you must— carried into the asynchronous world. Without those individualexceptionallys, the rating failure would have made the whole combination fail.
Solution to Exercise 3
package com.nexussoftware.bibliotech.service;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.LongAdder;
public class AsyncNoticeSending implements AutoCloseable {
record Notice(String id, String employee) { }
record NoticeResult(String id, boolean successful, String detail) {
static NoticeResult success(Notice n) {
return new NoticeResult(n.id(), true, "sent");
}
static NoticeResult failure(Notice n, String reason) {
return new NoticeResult(n.id(), false, reason);
}
}
record SendSummary(int total, int sent, int failed, long ms) {
double percentage() { return total == 0 ? 0 : 100.0 * sent / total; }
}
private final ExecutorService ioPool;
private final LongAdder completed = new LongAdder();
public AsyncNoticeSending() {
AtomicInteger n = new AtomicInteger(1);
this.ioPool = new ThreadPoolExecutor(
16, 16, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(300),
r -> new Thread(r, "bibliotech-notice-" + n.getAndIncrement()),
new ThreadPoolExecutor.CallerRunsPolicy());
}
/** An individual send: 250 ms of I/O and a simulated failure in some. */
private NoticeResult send(Notice n) {
try {
TimeUnit.MILLISECONDS.sleep(250);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new CompletionException(e);
}
if (n.id().hashCode() % 29 == 0) {
throw new IllegalStateException("employee has no contact address");
}
return NoticeResult.success(n);
}
/** The complete chain. It returns immediately. */
public CompletableFuture<SendSummary> sendAll(List<Notice> notices) {
long start = System.nanoTime();
final int total = notices.size();
// 1-2. One future per notice, SHIELDED with its own exceptionally.
// Without that shield, a single failure would fail the whole allOf.
List<CompletableFuture<NoticeResult>> futures = new ArrayList<>(total);
for (Notice n : notices) {
futures.add(CompletableFuture
.supplyAsync(() -> send(n), ioPool)
.exceptionally(e -> NoticeResult.failure(n, causeOf(e).getMessage()))
// Counted on finishing, in success or failure:
// that way main can show progress without blocking.
.whenComplete((r, e) -> completed.increment()));
}
// 3. Aggregate.
CompletableFuture<Void> all =
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
return all
// 4. Collect and summarise.
.thenApply(v -> {
int ok = 0, ko = 0;
for (CompletableFuture<NoticeResult> f : futures) {
// join() does not block: allOf guarantees they are complete.
if (f.join().successful()) ok++; else ko++;
}
return new SendSummary(total, ok, ko,
(System.nanoTime() - start) / 1_000_000);
})
// 5. Global deadline and final handler.
.orTimeout(60, TimeUnit.SECONDS)
.exceptionally(e -> {
System.err.println("Bulk send failed: " + causeOf(e).getMessage());
return new SendSummary(total, 0, total,
(System.nanoTime() - start) / 1_000_000);
});
}
public long completed() { return completed.sum(); }
static Throwable causeOf(Throwable t) {
return (t instanceof CompletionException || t instanceof ExecutionException)
&& t.getCause() != null ? t.getCause() : t;
}
@Override public void close() {
ioPool.shutdown();
try {
if (!ioPool.awaitTermination(30, TimeUnit.SECONDS)) ioPool.shutdownNow();
} catch (InterruptedException e) {
ioPool.shutdownNow();
Thread.currentThread().interrupt();
}
}
public static void main(String[] args) throws Exception {
List<Notice> notices = new ArrayList<>();
for (int i = 1; i <= 200; i++) {
notices.add(new Notice("NT-" + i, "employee-" + (i % 20)));
}
try (AsyncNoticeSending service = new AsyncNoticeSending()) {
System.out.println("=== ASYNCHRONOUS SENDING OF 200 NOTICES ===");
System.out.println("Sequential would be " + (200 * 250 / 1000) + " s\n");
// 6. The chain is declared and returns: main does NOT block.
CompletableFuture<SendSummary> chain = service.sendAll(notices);
// Progress, reading the LongAdder the tasks increment.
while (!chain.isDone()) {
long done = service.completed();
System.out.printf("\r [%s] %d/200 (%.0f%%)",
bar(done, 200), done, 100.0 * done / 200);
TimeUnit.MILLISECONDS.sleep(250);
}
SendSummary r = chain.get();
System.out.printf("\r [%s] 200/200 (100%%)%n%n", bar(200, 200));
System.out.println("=== SUMMARY ===");
System.out.println("Total : " + r.total());
System.out.println("Sent : " + r.sent());
System.out.println("Failed : " + r.failed());
System.out.println("Time : " + r.ms() + " ms");
System.out.printf ("Success : %.1f%%%n", r.percentage());
System.out.printf ("Speed-up : %.1fx%n", 200 * 250.0 / r.ms());
}
}
static String bar(long done, long total) {
int width = 30;
int filled = (int) (width * done / total);
return "#".repeat(filled) + "-".repeat(width - filled);
}
}Output:
=== ASYNCHRONOUS SENDING OF 200 NOTICES ===
Sequential would be 50 s
[##############################] 200/200 (100%)
=== SUMMARY ===
Total : 200
Sent : 193
Failed : 7
Time : 3387 ms
Success : 96.5%
Speed-up : 14.8xFour points:
- 14.8x speed-up with 16 threads. It is not 16x because of the coordination cost and because the last round does not fill every thread. 200 × 250 ms / 16 ≈ 3.1 s, very close to the result.
- The individual
exceptionallyis essential. Without it, one of the seven failures would have failed the wholeallOfand the summary would have been0 sent / 200 failed. Shielding each future before aggregating it is the mandatory pattern. - The
LongAdderincremented inwhenCompletemakes progress possible without blocking.mainreadscompleted()every 250 ms while the chain advances by itself. It is 08-06 and 08-07 working together. join()inside thethenApplyblocks nothing, becauseallOfhas already guaranteed that every future is complete. It is the one place wherejoin()is harmless inside a stage.
Conclusion
You have closed module 8. You started with two threads losing half a million increments and you finish with an application that does several things at once, correctly and without anybody waiting for anybody.
In this lesson you have seen why Future fell short: you cannot chain, you cannot combine, get() blocks, it takes no callbacks and it cannot be completed by hand. And you have learned the model that replaces it: instead of asking and waiting, declare the whole chain up front and let each stage fire when the previous one hands it a value.
You know how to create a CompletableFuture with supplyAsync when it produces a value, runAsync when it does not, completedFuture to unify the fast and slow paths, and the empty constructor to complete it by hand. And you know the decision with the greatest consequences: the executor. By default the ForkJoinPool.commonPool() is used, with cores - 1 threads shared by the whole JVM —including the parallel streams of 10-04—, so for any task that blocks you have to pass your own executor, sized according to 08-05 and with threads named according to 08-02. With the surprise worth remembering: on a single-core machine, the common pool has zero parallelism and your "asynchronous" code becomes synchronous without warning.
You have mastered transformation with thenApply, thenAccept and thenRun —the functional types of 04-06 applied—, and above all the distinction that confuses most: thenApply when the function returns a value, thenCompose when it returns another CompletableFuture, with the CompletableFuture<CompletableFuture<T>> that appears when you get it wrong and the analogy that fixes it: map versus flatMap. You know what the ...Async variants change —the thread that runs the stage— and the derived rule: no suffix for cheap transformations, suffix and your own executor for expensive work, because a suffix-free stage can end up running on the thread that completed the previous one or even on main.
You know how to combine: thenCombine to join two results computed in parallel —so the total time is that of the slowest, not the sum—, and allOf/anyOf for N futures, with the collection pattern —allOf and then a thenApply calling join(), safe because they are already complete— and the trap to avoid: shield each future with its own exceptionally before aggregating it, or one failure will take down the set.
And you handle errors in a world where no try/catch will do: exceptionally to recover with a fallback value, handle to unify the two paths, and whenComplete to observe without altering —the one used for metrics and traces—. You know how an exception travels: it short-circuits every following stage to the first handler, arrives wrapped in CompletionException with the real cause in getCause(), and after an exceptionally the chain carries on as normal. With the two classic mistakes clearly identified: putting the handler too far up, where it only covers what is above, and not putting one at all, so the failure disappears with no trace, no log and no exception — the same hole as submit without get() in 08-05.
You know Java 9's orTimeout and completeOnTimeout, with the essential warning that neither cancels the underlying work; manual completion with complete and completeExceptionally to adapt a callback API without touching it; and the real limit of cancellation: cancel(true) ignores its parameter and interrupts nothing, so real cancellation is still the shared flag and check points of 08-02.
BiblioTech, at the close of module 8, has stopped waiting.
Its catalogue import runs on its own named thread, publishes its progress, checks for cancellation once per line and —crucially— builds a separate list it only dumps into the catalogue if it finishes, so a cancellation never leaves the state half-done. Its two hundred notices are sent with a bounded pool of eight named threads, with a progress bar through CountDownLatch, with a semaphore limiting simultaneous file accesses to five, with every individual failure logged without stopping the batch and with a two-phase shutdown. Its catalogue is thread-safe —first with ReadWriteLock, then with ConcurrentHashMap and CopyOnWriteArrayList— and withstands one million six hundred thousand operations with sixteen threads in three hundred milliseconds without a single explicit lock(). Its loan registry maintains the invariant between its two maps under a single lock, because that is the only thing an invariant across structures admits, and exposes its compound operations as atomic methods so as not to hand races to whoever uses it. Its statistics are LongAdder and AtomicReference over immutable records: free reads, atomic writes, zero possible deadlocks. Its reservation queue is a real producer-consumer with ArrayBlockingQueue, with visible backpressure and a poison-pill shutdown that loses not one reservation. And its monthly report is generated with an asynchronous chain that loads two sources in parallel, computes on the CPU pool, writes on the I/O pool, and has a deadline, metrics and a final handler — while the menu serves eight options without stopping for an instant.
Of the four shortcomings you declared at the close of module 7, none remains: the import no longer blocks the menu, the notices no longer go one at a time, the processor is no longer idle waiting for a keystroke, and two employees working at once can no longer corrupt anything.
But all of that happens inside a single machine. BiblioTech is a program that runs on one computer and uses its cores, its memory and its files. Marta Ruiz can only use it if she sits in front of that computer. Diego Alonso, from another floor of the building, cannot. There is no way for the catalogue to be shared between Nexus Software's three sites, nor for an employee to check a book's availability from their laptop, nor for BiblioTech to ask an external service for the ISBN of a new title or genuinely send those notices it so far only writes to a local file. All the concurrency you have learned distributes work between threads of the same process; nothing you know so far allows it to be distributed between machines.
And there is a striking asymmetry: you have learned to overlap the I/O wait of a disk, which takes milliseconds, whereas a network wait takes hundreds of milliseconds and is exactly where all this pays off most. Pools sized for I/O, CompletableFuture, BlockingQueue and cooperative cancellation were designed above all with the network in mind.
In module 9, Networking, BiblioTech leaves its machine. You will see what really lies under a connection —IP addresses, ports, the layered model and the difference between TCP and UDP—; sockets as the endpoints of a conversation between two programs, with Socket on the client and ServerSocket on the server, and a server serving several clients at once with exactly the pools you have just learned; DatagramSocket for when speed matters more than the delivery guarantee; access to web resources with URL and HttpURLConnection; and Java 11's modern HTTP client, whose sendAsync returns —no coincidence— a CompletableFuture. By the end of it, BiblioTech's catalogue will be consulted from any computer at Nexus Software, and the application will be able to talk to external services. It will stop being alone.
Java Programming Course
Module 1: Introduction to Java
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
