Learn Java with Tests

A TDD course for Java, in the spirit of Learn Go with Tests.

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 in CompletionException; get() throws ExecutionException. In this chapter the tasks can’t throw, so join reads cleaner.
  • CompletableFuture is 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.