Lesson 1 · Predict → Run → Prove

Predict the ceiling

You understand the mechanism. Now put a number on it before you run anything — that's the difference between knowing and intuition.

One request to /orders/1 fans out to two downstream services concurrently, each stubbed at a fixed 50 ms. In broken mode, every supplyAsync(task) lands on the common pool, whose parallelism is availableProcessors() − 1. We'll pretend to be a 4-core container with -XX:ActiveProcessorCount=4.

The knowledge (30 seconds)

From Little's Law, concurrency = throughput × latency, rearranged to predict the cap:

throughput ≈ workers × (1 / latency) ÷ calls_per_request
Full derivation and command reference: the Runbook.

Fill in the three numbers for broken / 4 cores:

3 × 20 ÷ 2 = 30 req/s. Three workers, each doing 20 calls/second, but each request eats two of those calls — so the whole JVM tops out near 30 requests/second. The app's own startup banner prints this exact number. Offering more concurrency will not move it; it only grows the queue.

Now run it

The stack is already up (WireMock, Prometheus, Grafana) and the jar is pre-built. In a terminal:

cd ~/Desktop/thread-starvation-course/demo
./run-app.sh broken 4

Watch the startup banner — it prints availableProcessors() and common-pool parallelism, then its own throughput estimate. Then apply load from a second terminal:

./load.sh 50 60        # 50 concurrent clients, 60 seconds

Open Grafana :3000 → dashboard Thread Starvation — ForkJoin Common Pool. Read Row 1: throughput climbs, then flatlines. Compare the flatline to your locked-in guess above.

Prove it — don't just believe the graph

While the load runs, dump the threads:

jcmd $(jps | grep starvation | cut -d' ' -f1) Thread.print | grep -A5 commonPool

You'll see three ForkJoinPool.commonPool-worker threads. What state are they in — and is that a problem?

All three are parked (off-CPU) inside socketRead0, waiting on the 50 ms HTTP response. That is not a bug and not a deadlock — sleeping is the normal state for a thread doing network I/O. The problem isn't that they sleep; it's that there are only three of them for the entire JVM and ForkJoin spawns no compensating thread to drain the queue while they wait. Hold onto this: in Lesson 3 we'll use it to tear apart the idea that a "blocked" thread and a "blocked" connection are the same thing.
Primary source JDK 21 Javadoc — CompletableFuture. Read the class-level paragraph on the default executor: async methods with no executor run on ForkJoinPool.commonPool(). That one sentence is the entire bug.
Your teacher is in the chat. Tell me the throughput number you actually saw and I'll help you reconcile it with your prediction — or paste your jcmd output and we'll read it together. When you're ready, ask for Lesson 2, where we read p50 vs p99 and watch the mean lie to you.