Chapter 13 of 18
13 — Concurrency
ExecutorService, CompletableFuture, and virtual threads — submit everything, then join.
Kata: a WebsiteChecker that checks a list of URLs and reports which respond — checking them all at once.
New ideas: ExecutorService, Future, CompletableFuture, virtual threads — and why the loop that “looks right” isn’t actually parallel.
This chapter is a kata — write the code yourself. If you’re following along inside the solutions repo, the finished class also ships in
src/main/java/concurrency— don’t open it yet. Write each step from here first, run the tests, and watch the failures. The test in Step 1 is the hard part; the rest is the loop.
The problem: checking sites one by one
You’ll want to check each site as fast as possible. A sequential loop is the natural first attempt:
public Map<String, Boolean> check(List<String> urls) {
Map<String, Boolean> results = new HashMap<>();
for (String url : urls) {
results.put(url, checker.check(url));
}
return results;
}
Step 1 — the test that forces parallelism
How do you prove “all started at once” without a real network? Make the fake checker block on a latch:
@Test
@DisplayName("starts every check before any finishes — parallel, not sequential")
void checksSitesConcurrently() throws Exception {
int sites = 5;
List<String> urls = ...; // five fake URLs
CountDownLatch started = new CountDownLatch(sites);
CountDownLatch release = new CountDownLatch(1);
WebsiteChecker checker = new WebsiteChecker(url -> {
started.countDown();
awaitQuietly(release); // don't finish yet
return true;
});
// run checker.check(urls) on its own thread...
boolean allStarted = started.await(2, TimeUnit.SECONDS);
release.countDown();
assertTrue(allStarted, "all five checks should start while the checker is still blocked");
}
If the implementation is sequential, only one check ever starts before the first returns —
started.await(2, SECONDS) returns false, and the test fails. Deterministic: no sleeps, no
wall-clock flakiness. (The full test also asserts which URLs report as responding — see the
chapter file.)
Step 2 — RED: run your sequential loop against it
Run gradle test --tests "concurrency.WebsiteCheckerTest" with the naive loop:
WebsiteCheckerTest > starts every check before any finishes — parallel, not sequential FAILED
org.opentest4j.AssertionFailedError:
all five checks should start while the checker is still blocked ==> expected: <true> but was: <false>
The behaviour check isn’t about speed — the shape (one at a time) is what’s wrong.
Step 3 — the famous trap: a stream that joins as it maps
Upgrade to an ExecutorService + CompletableFuture:
return urls.stream()
.map(url -> CompletableFuture.supplyAsync(
() -> Map.entry(url, checker.check(url)), executor))
.map(CompletableFuture::join) // TRAP: joins each before submitting the next
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
Run it. It compiles… and fails the same way:
... ==> expected: <true> but was: <false>
Read the pipeline carefully: .map(CompletableFuture::join) blocks before the stream ever
reaches the next URL. It’s still sequential — just with extra steps. This is arguably the most
common concurrency bug in the Java world, and it’s invisible without a test like Step 1.
Step 4 — GREEN: submit everything first, then join
List<CompletableFuture<Map.Entry<String, Boolean>>> checks = urls.stream()
.map(url -> CompletableFuture.supplyAsync(
() -> Map.entry(url, checker.check(url)), executor))
.toList(); // terminal op: submits every task now
return checks.stream()
.map(CompletableFuture::join) // now the joins are safe — all already running
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
.toList() is the part that walks the whole stream — by the time it returns, every task is
already running. Step 1 turns green.
REFACTOR — virtual threads (JDK 21)
The last mystery is the executor. A fixed pool means deciding the pool size; a single
ExecutorService per call needs shutdown or it leaks threads. JDK 21 removes the dilemma:
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
...
}
Each task gets its own virtual thread — cheap enough to create one per task, so there’s no
pool to size. Blocking no longer ties up an expensive platform thread. And since Java 19,
ExecutorService is AutoCloseable: the try-with-resources awaits all work and shuts the pool
down for you.
Java-specific notes
join()wraps failures inCompletionException;get()throwsExecutionException. In this chapter the tasks can’t throw, sojoinreads cleaner.CompletableFutureis composable (thenApply,thenCombine,exceptionally) — that composition is the road into chapter 14.- Don’t reach for parallelism until a test or a profile proves the sequential version is the bottleneck — but when you do, keep the blocking I/O off the caller’s thread.
Run it
gradle test --tests "concurrency.*"
Key takeaway: concurrency bugs hide behind code that “looks right”. A test that proves N calls start simultaneously is worth more than any cleverness — and the fix is always the same shape: submit everything, then join.
Next: 14 — select — pick whichever completes first, or fail within a timeout.