Reference · Mechanics · Part 1 of 6
The whole bug starts here: your service quietly runs on two separate pools of threads, and blocking the tiny one freezes the big one. Get this picture straight and the rest of the series falls into place.
When you write CompletableFuture.supplyAsync(task) with no executor argument, the task runs on ForkJoinPool.commonPool() — a single, JVM-wide pool that you never created and that everything else in the process shares too. Add an executor argument and it runs there instead. That one missing argument is the entire defect:
// broken: no executor -> ForkJoinPool.commonPool() (shared, tiny)
CompletableFuture.supplyAsync(() -> client.call("/service-a"));
// fixed: your own pool, sized for the work
CompletableFuture.supplyAsync(() -> client.call("/service-a"), myExecutor);
In the demo this is the single branch inside OrderController.supply(...). Everything downstream in this series — the ceiling, the tail, the runaway, the health-check outage — flows from which side of that if you land on. You met the symptom in Lesson 1; here we look at why it happens.
Almost everyone pictures one pool of threads. There are two, and they do completely different jobs:
availableProcessors() − 1; on our "4-core" run (-XX:ActiveProcessorCount=4) that's 3. Shared across the entire JVM.
server.tomcat.threads.max: 200 — a real, typed setting. This is your front door.
The mismatch is the whole story: 200 threads at the front door, 3 threads doing the work behind it. The front door is wide; the kitchen is tiny.
Follow one call to /orders. It fans out to two downstream services at once, then waits for both:
supplyAsync — one for /service-a, one for /service-b. Both land on the 3-thread ForkJoin pool.a.join() and b.join(). join() parks that thread — it goes off-CPU and simply waits for the ForkJoin workers to finish.Two facts make this poisonous:
ForkJoinWorkerThread. It cannot help drain the ForkJoin queue while it waits — it just sits there.1/parallelism — a third here — of the whole JVM's async capacity, and nothing grows a replacement.3 cooks in the kitchen are your 3 ForkJoin workers. 200 waiters are your 200 Tomcat threads.
A waiter takes an order, carries it to the kitchen, and then stands at the kitchen window waiting for the food before they can do anything else. That standing-and-waiting is join() — the waiter is occupied but useless, unable to seat anyone or take another order until the cooks are done.
With only 3 cooks, food comes out slowly. So waiters pile up at the window, frozen, holding trays. When all 200 waiters are stuck at the window, you have "200/200 busy" — a full floor of staff and nobody able to greet a new customer.
Each /orders request occupies one Tomcat thread for its entire duration, and that duration includes the join() wait. Under load, the waits stack up until every one of the 200 Tomcat threads is parked on a join():
This is the counter-intuitive part. "200/200 busy" sounds like the app is working flat out. It isn't working at all — it's 200 threads frozen mid-wait. CPU is near idle, memory is fine, and yet nothing can get in. We'll see in Part 6 that this is exactly the moment even a trivial health check can't be answered.
There are two pools: a tiny one (3 ForkJoin workers) where the real work happens, and a big one (200 Tomcat threads) that is just the front door.
Block the tiny pool and it backs up into the big one: Tomcat threads pile up parked on join() until "200/200 busy" — a fully occupied front door that can't accept a thing, while the CPU sits idle.
ForkJoinPool javadoc — common-pool sizing is availableProcessors() − 1, and the pool is built for CPU-bound work with no compensation for blocked workers.
JDK 21 CompletableFuture javadoc — async methods with no executor run on ForkJoinPool.commonPool().
socketRead0.