Hands-on lab: size thread pools with Little's Law, bound queues, measure lock contention, fix CompletableFuture pools, and test virtual threads, pinning and gating on JDK 21 with real measurements.
Performance · 06
Concurrency, Thread Pools & Virtual Threads
OrderService serves 1,000 requests a second but CPU sits at a few percent and p99 is ten seconds. This lab finds out why: the limit is not CPU, it is the number of threads that can be parked inside a blocking call at once. We size a pool with Little's Law, watch queues and rejection policies behave, measure lock contention, show how CompletableFuture's default pool starves, then run the same blocking workload on a platform pool and on virtual threads, prove they do nothing for CPU-bound work, catch a pinned carrier red-handed, and gate a bounded resource. Every number comes from a program run on this machine.
The recurring scenario of this series is OrderService: a Spring Boot 3 / Java 21 service whose GET /orders/{id} calls a customer lookup, the InventoryService and the PaymentGateway over HTTP. In this chapter we isolate exactly that slice. Each request performs three sequential blocking calls of 50 ms and almost no CPU work, and we offer it a steady 1,000 requests per second. The service runs on a classic thread-per-request model: one thread owns a request from the first byte to the last.
Lab result: requests served per second at an offered load of 1,000/s (each request = 3 blocking calls x 50 ms)
1 Problem
▶
Offered load 1,000 req/s. With a pool of 50 worker threads the service completes only 329 req/s; average latency is 10,289 ms and p99 is 20,161 ms, while the CPU is almost idle.
2 Hypothesis
▶
Each request keeps a thread for 3 x 50 ms = 150 ms, so a pool of N threads can finish at most N / 0.150 s requests per second. 50 / 0.15 = 333/s. The pool is too small: requests queue behind busy threads.
3 Measurement
▶
PoolLab (open arrival model, latency charged from the intended arrival time), plus queue length and average requests in service (L).
4 Diagnosis
▶
Served rate sits on the formula (329 vs 333 predicted), the queue grows to 6,702 tasks, and the number of requests actually inside the service is capped at the pool size (L = 49.7). The latency is almost all queue wait.
5 Optimization (one variable)
▶
Only the pool size changes: 50 -> 100 -> 150 -> 200 -> 800, then a virtual thread per request.
6 Verification
▶
Pool 200 serves 985/s at p99 167 ms; virtual threads serve 985/s at p99 154 ms. Chapter 3 shows every step.
Key insight: A restaurant with 50 waiters, where every waiter stands at one table for the full 150 minutes between taking the order and bringing the bill. The kitchen (CPU) is nearly empty, yet at 6 tables per minute the line outside is endless. More cooks would not help; more waiters, or waiters who can walk away while the table thinks (virtual threads), would.
⚠ Common bugs & edge cases:
"CPU is low, so the service is healthy." A thread-bound service saturates on threads, not cores. Look at pool active-thread count, queue depth and in-flight requests (USE method: the resource is the pool).
Reading average latency only. The average (10,289 ms) already looks terrible here, but with a pool at the edge (150 threads) the average is 176 ms while p99 is 207 ms - averages hide the onset of queueing.
Measuring a closed loop. A load generator that waits for each response before sending the next one slows down when the service slows down and hides the queue. The lab uses an open model (see the Benchmarking page).
02
Chapter Two - Mental Model
Service Time, Little's Law and Pool Capacity
A request-handling thread has two kinds of time: C, the time it burns on a CPU, and W, the time it spends blocked (socket read, DB wait, lock wait). Here C is about a millisecond and W is 150 ms. Two relationships govern everything on this page.
Throughput ceiling of a pool
▶
max requests/s = N threads / time-in-service. For I/O-bound work the denominator is dominated by W, so you need many threads. N = 50, W = 0.15 s -> 333 req/s.
Little's Law L = lambda x W
▶
The average number of requests inside any stable system equals arrival rate times time in system. 1,000/s x 0.15 s = 150 concurrent requests. A thread-per-request server therefore needs about 150 threads at this load, regardless of how fast the CPU is.
Pool sizing rules of thumb
▶
CPU-bound: N is about cores (+1). I/O-bound: N is about cores x utilisation x (1 + W/C). With 12 cores, 1 ms CPU and 150 ms wait the formula allows ~1,800 threads before the CPU becomes the limit - far above what the downstream or the DB pool can serve.
Queues turn overload into latency
▶
When arrivals exceed capacity, a queue absorbs the difference. The queue length grows linearly with time, so latency grows without limit unless you bound the queue and reject.
Threads are not free
▶
A platform thread reserves ~1 MB of stack (ThreadStackSize = 1024 KB), costs an OS thread and context switches. 10,000 of them is possible but slow to create (measured in chapter 7); this is the problem virtual threads solve.
Anatomy of an I/O-bound request
Little's Law: how many threads does 1,000 req/s at 150 ms need?
Shell - the formulas as a calculator
# threads needed (Little) L = lambda * W
lambda = 1000 req/s, W = 0.150 s -> L = 150 threads in flight
# pool capacity X = N / W
N = 50 -> X = 50 / 0.150 = 333 req/s (measured: 329)
N = 100 -> X = 667 req/s (measured: 660)
N = 150 -> X = 1000 req/s = offered load, utilisation 100% -> queueing begins
# CPU-bound sizing (Goetz): N = cores + 1 = 13 on this 12-core machine
03
Chapter Three - Lab: Pool Sizing
Sizing a Thread Pool with Little's Law
Same code, same load, same machine. One variable changes: the number of worker threads. Each row is a separate 10-second run of PoolLab at 1,000 offered req/s (10,000 requests).
1 Problem
▶
Pool = 50 saturates (see chapter 1).
2 Hypothesis
▶
N / 0.15 s must exceed 1,000, so N must exceed 150; N = 100 should serve ~667/s, N = 150 is the knife edge, N = 200 has 33% headroom.
3 Measurement
▶
java PoolLab platform:N 1000 10, record served/s, avg, p95, p99, max queue length, average requests in service L.
4 Diagnosis
▶
Below 150 threads the queue grows for the whole run and latency is seconds; at 150 the pool just keeps up but p99 is already 207 ms (jitter queues briefly); at 170+ latency equals pure service time (about 150 ms). 800 threads gain nothing over 200.
5 Optimization (one variable)
▶
Pick N with headroom above L (here 200), not 10x above.
6 Verification
▶
Table below. The same run with virtual threads is in chapter 7.
Java - PoolLab: what one "request" does and how the pool is chosen (excerpt)
if (kind.startsWith("platform:")) {
pool = Executors.newFixedThreadPool(Integer.parseInt(kind.substring(9))); // unbounded LinkedBlockingQueue!
} else {
pool = Executors.newVirtualThreadPerTaskExecutor();
}
...
pool.execute(() -> {
inFlight.incrementAndGet();
for (int c = 0; c < CALLS; c++) Thread.sleep(CALL_MS); // stands in for a blocking HTTP call (3 x 50 ms)
inFlight.decrementAndGet();
lat[id] = System.nanoTime() - intended; // latency from the INTENDED arrival time
});
Real output - PoolLab, 1,000 req/s for 10 s (JDK 21.0.2, 12 cores)
Hockey stick: p99 latency vs pool size (offered load 1,000/s, log scale)
Verification of Little's Law: at 200 threads the sampler saw on average 148.8 requests inside the service; lambda x W = 1,000 x 0.153 s = 153. The law holds to within sampling error, which is why you can use it to predict pool size from two numbers you already have (RPS and latency).
⚠ Production gotchas
Cranking the pool to 800 "just in case". It worked here only because the downstream is a sleep. In production the next resource (DB connections: Hikari default 10, the PaymentGateway, a rate limit) becomes the bottleneck and extra threads just move the queue into the database. Size the pool from L, then size the downstream pools to match.
Tomcat defaults.server.tomcat.threads.max defaults to 200 and server.tomcat.accept-count to 100 (Spring Boot 3). A 200-thread cap at W = 150 ms is a hard 1,333 req/s limit per instance, and when it is hit the symptom is rising latency, not errors. Diagnose with the tomcat.threads.busy / tomcat.threads.config.max metrics (Boot exposes them when server.tomcat.mbeanregistry.enabled=true).
Several pools in series. Request thread (200) -> async executor (8) -> Hikari (10): the smallest one sets the throughput. Draw the chain and compute N/W for each.
04
Chapter Four - Lab: Queues and Rejection
Bounded Queues, Rejection Policies and Backpressure
Even with the right size, a pool needs a policy for overload. Java's ThreadPoolExecutor admits work in a fixed order, and most production incidents come from not knowing it: core threads first, then the queue, then extra threads up to max, then rejection.
Pool admission order: core -> queue -> max -> reject
1 Problem
▶
A burst of 30 x 200 ms tasks arrives at a pool of 4 threads with a 10-slot queue. What happens to the 30?
2 Hypothesis
▶
The pool can hold 4 running + 10 queued = 14. The other 16 hit the rejection policy.
3 Measurement
▶
RejectLab: same burst under three policies; count executed/rejected and time the submitting loop.
4 Diagnosis
▶
AbortPolicy throws immediately; CallerRuns makes the submitter do the work (it was blocked for ~800 ms - this is backpressure); DiscardPolicy loses work silently.
5 Optimization (one variable)
▶
Change only the RejectedExecutionHandler.
6 Verification
▶
Below: all 30 tasks executed only under CallerRuns.
Real output - RejectLab
AbortPolicy executed=14 rejected=16 submit-loop took 11 ms, all done in 829 ms ranOn={pool worker=14}
first RejectedExecutionException: Task RejectLab$$Lambda/0x00007dd9f0000a00@... rejected from java.util.concurrent.ThreadPoolExecutor@...[Running, pool size = 4, active threads = 4, queued tasks = 10, completed tasks = 0]
CallerRuns executed=30 rejected=0 submit-loop took 805 ms, all done in 1405 ms ranOn={main=4, pool worker=26}
DiscardPolicy executed=14 rejected=0 submit-loop took 3 ms, all done in 807 ms ranOn={pool worker=14}
Policy
Executed
Rejected
Submit loop
Behaviour
Use when
AbortPolicy (default)
14
16 (exception)
11 ms
RejectedExecutionException, fast failure
You can translate it to HTTP 503 / 429 and shed load
CallerRunsPolicy
30
0
805 ms
Submitting (e.g. Tomcat) thread runs the task; submission slows down
Internal queues where slowing the producer is the right answer
DiscardPolicy
14
0 (silent loss!)
3 ms
Drops the new task without any signal
Almost never; only for droppable telemetry
DiscardOldestPolicy
-
-
-
Drops the head of the queue and retries the new one
Latest-wins data (not run here)
Queue types - what each one means for overload
Queue
Capacity
Effect
Where you meet it
LinkedBlockingQueue (unbounded)
Integer.MAX_VALUE
Max-pool-size is never used; overload becomes memory growth and latency
Executors.newFixedThreadPool, newSingleThreadExecutor (the queue length was 6,702 in chapter 3)
SynchronousQueue
0 (hand-off)
No buffering: every task needs a free or new thread, up to max (unbounded for cached pools)
Executors.newCachedThreadPool - can create thousands of threads
ArrayBlockingQueue / bounded LinkedBlockingQueue
you choose
Overload is visible: after the queue fills, threads grow to max, then rejection
Custom pools; the recommended default
Spring ThreadPoolTaskExecutor
queueCapacity
Boot's auto-configured applicationTaskExecutor has an unbounded queue and core-size 8 (spring.task.execution.pool.*)
@Async, @Scheduled async work
How to size the queue (a rule you can defend)
Queue length ≤ (acceptable extra wait) x (pool throughput). With 200 threads at 150 ms the pool completes ~1,333/s; to tolerate 300 ms of queueing allow ~400 slots.
Anything beyond that only produces responses the client has already timed out on. Reject early, return 503 with Retry-After, and let the load balancer or the client retry elsewhere.
⚠ Production gotchas
Executors.newFixedThreadPool in production. The unbounded queue hides overload until the heap dies (java.lang.OutOfMemoryError: Java heap space with a huge LinkedBlockingQueue in the histogram - see the JVM Memory page). Always construct ThreadPoolExecutor with a bounded queue and an explicit handler.
CallerRuns on a Tomcat thread that submits to a pool which calls back into the web tier can deadlock or amplify the overload. Use it only where blocking the producer is safe.
Silent DiscardPolicy. No log, no metric: orders vanish. Wrap it, count the discards, or do not use it.
05
Chapter Five - Lab: Contention
Lock Contention, Convoys and Hot Counters
Not every wait is I/O. When threads queue for the same lock the pool looks busy (threads in BLOCKED state), CPU stays low, and adding threads makes it worse. Two shapes to recognise: a long critical section (a legacy synchronized block that does I/O or a 2 ms recomputation) and a hot counter (many threads hammering one cache line).
1 Problem
▶
Throughput of an endpoint stays flat at ~400 req/s whether 4 or 40 request threads are used; thread dump shows most threads BLOCKED.
2 Hypothesis
▶
All requests pass through one synchronized block that holds the monitor for ~2 ms, so the endpoint is effectively single-threaded: 1 / 2 ms = 500/s at best.
3 Measurement
▶
jcmd PID Thread.print (or jstack) taken while the load is running; count BLOCKED threads and look at the - waiting to lock address; later JFR event jdk.JavaMonitorEnter.
4 Diagnosis
▶
One thread is TIMED_WAITING (sleeping) while holding locked <0x...>; all seven others are BLOCKED on that same address.
5 Optimization (one variable)
▶
Shorten the critical section (compute outside the lock, copy-on-write, ConcurrentHashMap.computeIfAbsent for the cache) or remove the shared state.
6 Verification
▶
Re-take the dump: BLOCKED count should fall to ~0; throughput scales with threads again.
Real output - jcmd PID Thread.print, 8 request threads sharing one lock (trimmed to two threads)
"http-nio-8080-exec-1" #20 [1535238] daemon prio=5 os_prio=0 cpu=6.89ms elapsed=1.80s tid=0x000075564016f3b0 nid=1535238 waiting for monitor entry [0x0000755611efe000]
java.lang.Thread.State: BLOCKED (on object monitor)
at LockDump.refresh(LockDump.java:7)
- waiting to lock <0x000000062f012dd0> (a java.lang.Object)
at LockDump.lambda$main$0(LockDump.java:15)
...
"http-nio-8080-exec-3" #22 [1535240] daemon prio=5 os_prio=0 cpu=6.67ms elapsed=1.80s tid=0x00007556401716e0 nid=1535240 waiting on condition [0x0000755611cfe000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleep0(java.base@21.0.2/Native Method)
at java.lang.Thread.sleep(java.base@21.0.2/Thread.java:509)
at LockDump.refresh(LockDump.java:8)
Java - LockDump: the pattern behind that dump
staticfinalObjectCACHE_LOCK = newObject();
staticvoidrefresh() throws InterruptedException {
synchronized (CACHE_LOCK) { // every request serialises hereThread.sleep(2); // 2 ms of work (or worse: I/O) while holding the monitor
refreshed++;
}
}
Result of that run: 8 threads completed 1,621 refreshes in 4 seconds (about 405/s) - barely below the 500/s single-thread ceiling. Seven of the eight threads were BLOCKED on the monitor.
The second shape is hot-spot contention on tiny critical sections. ContentionLab increments one shared counter from 1 to 12 threads, using four primitives. The critical section is nanoseconds long, so the cost is the lock hand-off and cache-line ping-pong, not waiting for work.
Real output - ContentionLab (million increments per second; 2 s per cell)
Lab result: one shared counter, million increments per second vs number of threads
Threads
synchronized
ReentrantLock
AtomicLong (CAS)
LongAdder (striped)
1
29.2
42.5
94.2
81.5
2
20.6
7.6
29.1
132.4
4
10.1
21.0
18.4
282.1
8
6.8
23.8
25.3
520.0
12
8.4
25.2
26.5
576.3
Reading it: with one thread the lock-based counters are fast (no contention, thin locks); as threads are added they do not scale and often get slower, because every increment needs exclusive ownership of the same cache line and the lock transfers between cores. LongAdder keeps per-thread cells and only sums on read, so it keeps scaling. The price: sum() is not an atomic snapshot - fine for metrics, wrong for a sequence number.
A decision rule for shared state in a request path
Read-mostly cache:ConcurrentHashMap + immutable values, or Caffeine. Never a synchronized HashMap.
Metrics / counters:LongAdder (Micrometer already does this).
Compound invariant across fields: a lock is correct; keep the critical section free of I/O and loops, and prefer ReentrantLock.tryLock(timeout) so a stuck holder surfaces as an error instead of a pile-up.
Find it in production: JFR jdk.JavaMonitorEnter (blocked time per monitor class) and several thread dumps 5 s apart; the same lock address in >1 dump = contention, not coincidence.
⚠ Production gotchas
Doing I/O inside synchronized. It converts a nanosecond lock into a 50 ms lock, and with virtual threads on JDK 21 it also pins the carrier (chapter 9).
Reading "BLOCKED" as "slow code". The BLOCKED threads are the victims; the interesting one is the thread that holds the monitor (- locked <0x...>) and what it is doing.
Using parallel hardware to fix a serial section. By Amdahl's law (see Measure First) a 2 ms critical section out of a 3 ms request caps speed-up at 1.5x no matter how many cores you add.
06
Chapter Six - Lab: CompletableFuture
CompletableFuture Fan-Out and the Pools Behind It
CompletableFuture.supplyAsync(supplier)without an executor runs on ForkJoinPool.commonPool() - a pool sized for CPU-bound work (availableProcessors() - 1). It is also the pool used by parallel streams. Putting blocking calls on it is the classic way to turn a 12-core server into an 11-call-at-a-time server.
1 Problem
▶
A fan-out endpoint (customer + inventory + payment in parallel, then combine) finishes in 5.5 s for 400 requests although each call takes 50 ms.
2 Hypothesis
▶
The three calls per request are blocking, the common pool has 11 threads, so only 11 calls run at a time: 1,200 calls x 50 ms / 11 = ~5.45 s.
3 Measurement
▶
CfLab: the same composition code, only the executor argument differs.
4 Diagnosis
▶
Total time matches 1,200 x 50 / 11 almost exactly; a dedicated 64-thread pool gives 1,200 x 50 / 64 = 940 ms (measured 1,023 ms); 1,200 threads or virtual threads let every call run at once.
5 Optimization (one variable)
▶
Pass an executor sized for blocking I/O (or a virtual-thread executor) to every supplyAsync / thenApplyAsync.
6 Verification
▶
Table below.
Real output - CfLab
availableProcessors=12 commonPool parallelism=11
commonPool (default, no executor) 400 requests x 3 parallel 50ms calls: 5537 ms
dedicated fixed pool of 64 400 requests x 3 parallel 50ms calls: 1023 ms
dedicated fixed pool of 1200 400 requests x 3 parallel 50ms calls: 279 ms
virtual-thread executor 400 requests x 3 parallel 50ms calls: 98 ms
Lab result: 400 requests x 3 parallel blocking calls (50 ms) - time to finish all
Java - the composition (same for every row; only the executor changes)
CompletableFuture<String> cust = CompletableFuture.supplyAsync(() -> call("customer"), executor);
CompletableFuture<String> inv = CompletableFuture.supplyAsync(() -> call("inventory"), executor);
CompletableFuture<String> pay = CompletableFuture.supplyAsync(() -> call("payment"), executor);
return cust.thenCombine(inv, (a, b) -> a + b).thenCombine(pay, (a, b) -> a + b); // runs when all three complete
⚠ Production gotchas
supplyAsync(blockingCall) with no executor. Shares the 11-thread common pool with every parallel stream and every other CompletableFuture in the JVM: one slow dependency stalls unrelated code. Symptom: threads named ForkJoinPool.commonPool-worker-N all in Socket.read in a thread dump.
1-2 CPU containers. If the common pool parallelism is 1 or less, CompletableFuture silently falls back to creating a new thread per task; on a 4-CPU Fargate task the common pool has just 3 threads. Never rely on it for blocking work.
join() inside a pool task. A task that blocks waiting for another task in the same bounded pool can deadlock it (thread starvation deadlock). Use separate pools per stage, or non-blocking composition.
No timeout.orTimeout(300, MILLISECONDS) / completeOnTimeout on every downstream future; otherwise a hung payment gateway holds a pool thread for the socket timeout (often infinite).
07
Chapter Seven - Lab: Virtual Threads
Virtual Threads: Mechanics and the Blocking-I/O Win
A virtual thread (JEP 444, final in JDK 21) is a java.lang.Thread scheduled by the JVM, not the OS. When it blocks in a supported operation (socket I/O, Thread.sleep, LockSupport.park, java.util.concurrent locks, queues), the JVM saves its stack to the heap and frees the carrier thread to run another virtual thread. The code stays plain blocking code; you simply stop being limited to a few hundred OS threads.
Virtual threads: mount, unmount, pin
1 Problem
▶
Chapter 3 needed 170-200 platform threads to carry 1,000 req/s x 150 ms, and a Tomcat-style 200-thread cap is a hard ceiling of ~1,333 req/s.
2 Hypothesis
▶
If each request gets its own virtual thread, the number of concurrent requests is no longer bounded by a pool, so a blocking workload needs no pool tuning.
3 Measurement
▶
Re-run PoolLab with Executors.newVirtualThreadPerTaskExecutor(); measure creation cost with ThreadCost.
4 Diagnosis
▶
Served 985/s at p99 154 ms with no queue; average in service L = 147.9 (= 1,000 x 0.150). Ten thousand virtual threads start in 316 ms vs 9116 ms for platform threads.
5 Optimization (one variable)
▶
Swap the executor only. In Spring Boot 3.2+ on JDK 21 the same switch is spring.threads.virtual.enabled=true (Tomcat request threads and the application task executor).
Real output - ThreadCost (RSS while N threads are blocked)
--- platform 10000
before: VmRSS: 40564 kB
platform x 10000 all started in 9116 ms
while all blocked: VmRSS: 134684 kB
--- virtual 10000
before: VmRSS: 40452 kB
virtual x 10000 all started in 316 ms
while all blocked: VmRSS: 92904 kB
--- virtual 100000
before: VmRSS: 40516 kB
virtual x 100000 all started in 688 ms
while all blocked: VmRSS: 256108 kB
Lab result: time (ms) to start N threads that then block for 3 s
Platform thread
Virtual thread
Scheduled by
OS kernel
JVM (ForkJoinPool in FIFO mode; parallelism = cores by default)
Stack
Fixed reservation (ThreadStackSize 1024 KB default), committed as touched
Grows/shrinks on the heap as stack chunks while unmounted
Creation
Expensive (10,000 took 9116 ms here)
Cheap (10,000 took 316 ms, 100,000 took 688 ms)
Pool it?
Yes (that is why pools exist)
No. One per task; pooling them defeats the point. Use a semaphore to limit concurrency, not a pool to limit threads
Daemon?
Configurable
Always daemon
Good for
CPU-bound, long-lived, native calls
Blocking I/O fan-out, thread-per-request servers
Blocking synchronized
Normal
Pins the carrier on JDK 21; mostly fixed in JDK 24 (JEP 491)
Application properties - virtual threads in Spring Boot 3.2+ (JDK 21+)
spring.threads.virtual.enabled=true
# effect (Boot 3.2+): Tomcat/Jetty handle each request on a virtual thread, and# the auto-configured applicationTaskExecutor (@Async, etc.) runs tasks on virtual threads# NOT affected: your own ExecutorService beans, @Scheduled thread pool settings, JDBC pool size (Hikari)
⚠ Production gotchas
Pooling virtual threads.Executors.newFixedThreadPool(200, Thread.ofVirtual().factory()) reintroduces the 200-concurrency cap you were trying to remove.
Expecting speed-ups for CPU work (chapter 8) or for a service whose bottleneck is the DB pool (chapter 10).
ThreadLocal habits. Virtual threads support ThreadLocal, but with a million virtual threads a per-thread 100 KB buffer is 100 GB. Cache per-carrier resources in pools, not in ThreadLocals; ScopedValue is the intended replacement but is still preview in JDK 21 (needs --enable-preview).
Native/JNI frames and Object.wait() also pin on JDK 21 (diagnose with -Djdk.tracePinnedThreads=full, chapter 9).
08
Chapter Eight - Lab: CPU-bound Work
CPU-Bound Work: No Gain from Virtual Threads
Virtual threads multiply the number of waiting tasks you can hold. They do nothing about the number of tasks the CPUs can execute. A CPU-bound request (hashing an order payload, compressing a response, rendering a PDF) occupies a carrier for its whole duration.
1 Problem
▶
Someone enables virtual threads on the OrderService PDF-invoice endpoint expecting more throughput.
2 Hypothesis
▶
CPU-bound work is limited by cores; N = cores is already optimal, more threads only add scheduling overhead.
3 Measurement
▶
CpuLab: 96,000 tasks of ~0.5 ms CPU each on pools of 4, 12 (= cores), 200 platform threads and a virtual thread per task; three full runs.
4 Diagnosis
▶
Four threads reach about half of the 12-thread throughput (median 8207 vs 16547 tasks/s). Beyond 12 there is no multiple: 200 platform threads 20458 tasks/s, virtual threads 16018 tasks/s (host is shared, so +/-25% is scheduling noise: the identical 12-thread pool measured between 15312 and 16608).
5 Optimization (one variable)
▶
None - the right action is to not change the executor, or to reduce work per request (cache the PDF, compress less).
6 Verification
▶
Table below.
Real output - CpuLab (run 1 of 3)
cores = 12
platform pool = 4 13.01 s 7380 tasks/s
platform pool = 12 (=cores) 6.27 s 15312 tasks/s
platform pool = 200 4.69 s 20458 tasks/s
virtual thread per task 5.99 s 16018 tasks/s
platform pool = 12 (again) 5.40 s 17764 tasks/s
Lab result: CPU-bound hashing, tasks per second, median of 3 runs (higher is better)
Where does the time go? A one-line triage
top -H -p PID: a few threads at ~100% CPU = CPU-bound (profile with JFR, see Profiling Toolbox). Many threads in S state, CPU low = waiting (this page).
Mixed workloads: keep CPU-heavy work on a bounded platform pool sized to cores; let virtual threads orchestrate the waiting. A virtual thread that computes for 100 ms monopolises its carrier just like a platform thread would.
09
Chapter Nine - Lab: Pinning
Pinned Carriers: Catching synchronized Red-Handed
On JDK 21 a virtual thread that blocks while holding a monitor (inside a synchronized block or method) cannot unmount: it stays mounted and blocks its carrier. If every carrier is pinned, no other virtual thread can run - the service looks hung even though thousands of virtual threads exist. This is the most common reason a "we turned on virtual threads" rollout disappoints.
1 Problem
▶
After enabling virtual threads, throughput barely changes under a legacy library that does I/O inside synchronized.
2 Hypothesis
▶
Each such call pins a carrier for its whole duration, so the effective concurrency is the carrier count (parallelism = cores by default), not the number of virtual threads.
3 Measurement
▶
PinLab: 400 virtual threads, each holding its own lock (no contention!) while blocking 50 ms; compare synchronized vs ReentrantLock; -Djdk.virtualThreadScheduler.parallelism=4 to exaggerate; -Djdk.tracePinnedThreads=full; a virtual-thread thread dump.
4 Diagnosis
▶
synchronized: 5073 ms with 4 carriers (= 400 x 50 / 4), 1731 ms with 12 carriers; ReentrantLock: 87 and 118 ms. The pinned-thread trace names the frame: PinLab.lambda$main$0 <== monitors:1. The dump of a pinned virtual thread parks via parkOnCarrierThread.
5 Optimization (one variable)
▶
Replace the synchronized block with a ReentrantLock (only the lock type changes).
6 Verification
▶
Same 400 x 50 ms finishes in 87 ms (4 carriers) instead of 5073 ms.
Real output - PinLab
synchronized: 400 tasks x 50 ms finished in 5073 ms (ideal with no pinning: ~50 ms)
ReentrantLock: 400 tasks x 50 ms finished in 87 ms (ideal with no pinning: ~50 ms)
synchronized: 400 tasks x 50 ms finished in 1731 ms (ideal with no pinning: ~50 ms)
ReentrantLock: 400 tasks x 50 ms finished in 118 ms (ideal with no pinning: ~50 ms)
Lab result: 400 virtual threads each blocking 50 ms inside their OWN lock (no contention)
Real output - java -Djdk.tracePinnedThreads=full ... PinLab sync (first record)
Thread[#42,ForkJoinPool-1-worker-3,5,CarrierThreads]
java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:183)
java.base/jdk.internal.vm.Continuation.onPinned0(Continuation.java:393)
java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:621)
java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:791)
java.base/java.lang.Thread.sleep(Thread.java:507)
PinLab.sleep(PinLab.java:37)
PinLab.lambda$main$0(PinLab.java:24) <== monitors:1
java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
java.base/java.lang.VirtualThread.run(VirtualThread.java:309)
synchronized: 400 tasks x 50 ms finished in 5082 ms (ideal with no pinning: ~50 ms)
Real output - jcmd PID Thread.dump_to_file -format=text: a PINNED virtual thread (note parkOnCarrierThread)
Does blocking inside synchronized pin the carrier?
Yes
No - JEP 491 (JDK 24) lets virtual threads unmount while holding a monitor
Object.wait() inside synchronized
Pins
Fixed by JEP 491
Native (JNI) frame or foreign call on the stack
Pins
Still pins
ReentrantLock, Semaphore, BlockingQueue
Fine (unmount)
Fine
Class initialisers (<clinit>) blocking
Pins
Still pins
⚠ Production gotchas
Assuming "my code has no synchronized". Old JDBC drivers, some logging appenders, Hystrix-era libraries and even PrintStream use it internally. Find them with -Djdk.tracePinnedThreads=full in a load test, or the JFR event jdk.VirtualThreadPinned (reported when a pinned park exceeds 20 ms by default).
Raising jdk.virtualThreadScheduler.maxPoolSize as a fix. The scheduler can compensate for some blocking by adding carriers, but not for monitor-pinned parks; and it just rebuilds a big OS thread pool. Fix the lock, upgrade the library or the JDK.
Reading a normal jstack.Thread.print shows only carriers (Carrying virtual thread #N) - use Thread.dump_to_file to see virtual threads themselves.
10
Chapter Ten - Lab: Bounded Resources
Virtual Threads Need a Gate: Protecting the Connection Pool
Virtual threads remove the thread limit. They do not remove the limit of whatever the threads are waiting for: the Hikari pool (default maximumPoolSize = 10), the PaymentGateway's rate limit, a licence server. With 200 platform threads the thread pool was an accidental throttle. With one virtual thread per request there is no throttle left, so overload goes straight to the bounded resource, and every waiter holds memory while it waits.
1 Problem
▶
A 3x overload (3,000 req/s offered, capacity 1,000/s) against a 20-connection database stand-in whose queries take 20 ms.
2 Hypothesis
▶
Without a gate every request waits up to 500 ms for a connection, so most of them time out after wasting half a second.
3 Measurement
▶
GateLab: virtual thread per request, Semaphore(20) as the connection pool; mode gate adds an admission Semaphore(120) (20 running + 100 waiting) with tryAcquire() that fails fast.
4 Diagnosis
▶
No gate: 3,205 ok / 5,795 timed out, successful requests waited p50 519 ms. Gate: 2,918 ok at p50 123 ms, 6,082 rejected instantly.
5 Optimization (one variable)
▶
Add the admission semaphore (the only change).
6 Verification
▶
Successful throughput is the same (~1,000/s, bounded by the DB), latency drops about 4x, and zero requests time out.
Real output - GateLab
nogate offered=9000 ok=3205 timeout(500ms)=5795 rejected-fast=0 wall=3530 ms ok-latency p50=519ms p99=533ms
gate offered=9000 ok=2918 timeout(500ms)=0 rejected-fast=6082 wall=3123 ms ok-latency p50=123ms p99=139ms
Per downstream, not global: one Semaphore (or Resilience4j Bulkhead) for the DB, another for PaymentGateway, sized by their Little numbers (capacity x latency).
Hikari: keep connectionTimeout well below the request timeout (default 30000 ms means a request can hang 30 s before SQLTransientConnectionException: ... Connection is not available, request timed out after 30000ms). See the Database page.
At the edge: ALB / API gateway rate limits and a per-instance concurrency limit make overload visible as 429/503 instead of latency.
⚠ Production gotchas
Turning on virtual threads with a 10-connection Hikari pool and 2,000 concurrent requests: 1,990 virtual threads wait inside Hikari, each holding request state, all failing together at the 30 s timeout.
ThreadLocal-cached connections or per-thread resources: with virtual threads the number of "threads" is the number of in-flight requests, so per-thread caches explode.
11
Chapter Eleven - Structured Concurrency (Preview)
Fan-Out Without Orphans
PREVIEW in JDK 21Structured concurrency (JEP 453, StructuredTaskScope) treats a group of concurrent subtasks as one unit of work: subtasks are forked inside a scope, the scope is joined, and when the block exits no subtask can still be running. It needs --enable-preview on JDK 21 and its API may change in later releases, so treat the code below as a technique to know, not as something to ship without that in mind.
1 Problem
▶
A fan-out where payment fails after 80 ms keeps the slow inventory call (400 ms) running, because nothing tells it to stop.
2 Hypothesis
▶
With plain futures the sibling keeps running unless you cancel it by hand; with a ShutdownOnFailure scope it is interrupted as soon as one subtask fails.
3 Measurement
▶
ScopeLab compiled with --release 21 --enable-preview.
4 Diagnosis
▶
Success path: 472 ms (bounded by inventory). Failure path: payment fails at ~80 ms, inventory is interrupted, the order fails at 87 ms.
5 Optimization (one variable)
▶
Wrap the calls in StructuredTaskScope.ShutdownOnFailure.
6 Verification
▶
The failed order is reported at 87 ms instead of after inventory's 400 ms, and no orphan task keeps running (the log line shows inventory cancelled).
Real output - ScopeLab (javac --release 21 --enable-preview; java --enable-preview)
all calls succeed:
[customer] done
[payment] done
[inventory] done
result = customer+inventory+payment
elapsed 472 ms
payment fails after 80 ms (inventory would take 400 ms):
[customer] done
[inventory] cancelled after scope shut down
failed fast: java.lang.IllegalStateException: payment returned 503
elapsed 87 ms
Java - structured fan-out (PREVIEW API in JDK 21)
try (var scope = newStructuredTaskScope.ShutdownOnFailure()) {
Subtask<String> cust = scope.fork(() -> call("customer"));
Subtask<String> inv = scope.fork(() -> call("inventory"));
Subtask<String> pay = scope.fork(() -> call("payment"));
scope.join().throwIfFailed(); // waits; on the first failure the others are interruptedreturn cust.get() + inv.get() + pay.get();
}
CompletableFuture
StructuredTaskScope (preview)
Failure of one branch
Siblings keep running unless you cancel them by hand
ShutdownOnFailure interrupts siblings
Lifetime
Futures can outlive the method that started them (leaks)
Subtasks cannot outlive the try block
Thread dump / observability
Tasks have no parent link
Subtasks are children of the owner thread in Thread.dump_to_file JSON
Runs on
Any executor
One virtual thread per subtask
Status in JDK 21
Stable
Preview (--enable-preview); ScopedValue likewise
12
Chapter Twelve - Try It Yourself
The Complete Programs
Everything above ran from plain Java files, no Spring needed. Compile and run them on any JDK 21 machine; your absolute numbers will differ (core count, laptop vs server, noisy neighbours) but the shapes - the cliff at N = lambda x W, the BLOCKED pile-up, the pinned carriers - will reproduce.
Shell - build and run the labs
javac PoolLab.java CpuLab.java PinLab.java GateLab.java ContentionLab.java CfLab.java RejectLab.java ThreadCost.java LockDump.java
javac --release 21 --enable-preview ScopeLab.java # structured concurrency is a PREVIEW API in 21
# 1. thread-pool sizing (chapter 3): pool size is the only variable
java PoolLab platform:50 1000 10
java PoolLab platform:200 1000 10
java PoolLab virtual 1000 10
# 2. pinning (chapter 9): 4 carriers make it obvious
java -Djdk.virtualThreadScheduler.parallelism=4 -Djdk.virtualThreadScheduler.maxPoolSize=4 PinLab sync
java -Djdk.virtualThreadScheduler.parallelism=4 -Djdk.virtualThreadScheduler.maxPoolSize=4 PinLab lock
java -Djdk.tracePinnedThreads=full -Djdk.virtualThreadScheduler.parallelism=4 PinLab sync
# 3. thread dump of virtual threads while PinLab runs
jcmd PID Thread.dump_to_file -format=text /tmp/vt-dump.txt
# 4. the rest
java GateLab nogate ; java GateLab gate ; java CfLab ; java RejectLab ; java CpuLab ; java ContentionLab
java --enable-preview ScopeLab
Java - PoolLab.java (complete, as run)
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
/**
* PoolLab - thread-per-request OrderService. Each request = 3 sequential BLOCKING downstream calls of 50 ms
* (customer, inventory, payment) and ~0CPU. RequestsARRIVE at a fixed rate (open model), so a saturated pool
* shows up as queueing delay, exactly as in production.
*
* java PoolLab platform:200100010 -> fixed pool of 200 platform threads, 1000 req/s for10 s
* java PoolLab virtual 100010 -> one virtual thread per request
*/
publicclassPoolLab {
staticfinalintCALLS = 3, CALL_MS = 50;
staticfinalAtomicInteger inFlight = newAtomicInteger();
staticfinalLongAdder inFlightSamples = newLongAdder(), inFlightSum = newLongAdder();
publicstaticvoidmain(String[] a) throws Exception {
String kind = a[0];
int rate = Integer.parseInt(a[1]);
int seconds = Integer.parseInt(a[2]);
ExecutorService pool;
ThreadPoolExecutor tpe = null;
if (kind.startsWith("platform:")) {
tpe = (ThreadPoolExecutor) Executors.newFixedThreadPool(Integer.parseInt(kind.substring(9)));
pool = tpe;
} else {
pool = Executors.newVirtualThreadPerTaskExecutor();
}
int total = rate * seconds;
long[] lat = newlong[total];
AtomicInteger done = newAtomicInteger();
CountDownLatch all = newCountDownLatch(total);
AtomicInteger maxQueue = newAtomicInteger();
// sampler for L (average requests inside the service) and queue lengthScheduledExecutorService sampler = Executors.newSingleThreadScheduledExecutor();
finalThreadPoolExecutor tp = tpe;
sampler.scheduleAtFixedRate(() -> {
inFlightSum.add(inFlight.get()); inFlightSamples.increment();
if (tp != null) maxQueue.accumulateAndGet(tp.getQueue().size(), Math::max);
}, 100, 10, TimeUnit.MILLISECONDS);
long interval = 1_000_000_000L / rate;
long start = System.nanoTime() + 100_000_000L;
for (int i = 0; i < total; i++) {
long intended = start + i * interval;
long now;
while ((now = System.nanoTime()) < intended) { if (intended - now > 300_000) java.util.concurrent.locks.LockSupport.parkNanos(intended - now - 200_000); }
finalint id = i;
pool.execute(() -> {
inFlight.incrementAndGet();
try {
for (int c = 0; c < CALLS; c++) Thread.sleep(CALL_MS); // stands in for a blocking HTTP call
} catch (InterruptedException e) { return; }
finally { inFlight.decrementAndGet(); }
lat[id] = System.nanoTime() - intended; // from INTENDED arrival
all.countDown();
});
}
long submitEnd = System.nanoTime();
all.await();
long wall = System.nanoTime() - start;
sampler.shutdownNow();
pool.shutdown();
long[] s = lat.clone();
Arrays.sort(s);
doubleW = Arrays.stream(lat).average().orElse(0) / 1e9;
System.out.printf("%-14s offered=%d/s served=%.0f/s avg=%.0fms p50=%.0fms p95=%.0fms p99=%.0fms max=%.0fms maxQueue=%d avgInService(L)=%.1f%n",
kind, rate, total / (wall / 1e9), W * 1000, p(s, .50), p(s, .95), p(s, .99), s[s.length - 1] / 1e6, maxQueue.get(),
(double) inFlightSum.sum() / Math.max(1, inFlightSamples.sum()));
}
staticdoublep(long[] s, double q) { return s[(int) Math.min(s.length - 1, Math.ceil(q * s.length) - 1)] / 1e6; }
}
Java - PinLab.java (complete, as run)
import java.util.concurrent.*;
import java.util.concurrent.locks.*;
/**
* PinLab - virtual-thread pinning (JDK21: blocking inside synchronized pins the carrier; fixed in JDK24, JEP491).
* 400 virtual threads each hold THEIROWN lock while making a 50 ms blocking call, so there is no lock contention at all.
* Run with a small carrier pool to make the effect obvious:
* java -Djdk.virtualThreadScheduler.parallelism=4 -Djdk.virtualThreadScheduler.maxPoolSize=4PinLab sync
* java -Djdk.virtualThreadScheduler.parallelism=4 -Djdk.virtualThreadScheduler.maxPoolSize=4PinLab lock
*/
publicclassPinLab {
publicstaticvoidmain(String[] a) throws Exception {
boolean useSync = a[0].equals("sync");
long ms = a.length > 1 ? Long.parseLong(a[1]) : 50; // blocking time per taskint n = 400;
Object[] monitors = newObject[n];
ReentrantLock[] locks = newReentrantLock[n];
for (int i = 0; i < n; i++) { monitors[i] = newObject(); locks[i] = newReentrantLock(); }
long t0 = System.nanoTime();
try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < n; i++) {
finalint id = i;
ex.submit(() -> {
if (useSync) {
synchronized (monitors[id]) { sleep(ms); } // legacy driver: synchronized + blocking I/O
} else {
locks[id].lock();
try { sleep(ms); } finally { locks[id].unlock(); }
}
returnnull;
});
}
}
System.out.printf("%s: %d tasks x %d ms finished in %d ms (ideal with no pinning: ~%d ms)%n",
useSync ? "synchronized" : "ReentrantLock", n, ms, (System.nanoTime() - t0) / 1_000_000, ms);
}
staticvoidsleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }
}
Java - GateLab.java (complete, as run)
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
/**
* GateLab - virtual threads remove the thread limit, NOT the connection limit.
* The "database" has 20 connections and every query takes 20 ms => capacity = 20 / 0.020 = 1000 queries/s.
* Overload: 3000 requests/s arrive for3s (3x capacity).
* mode "nogate": every request waits for a connection up to 500 ms, then fails
* ("Connection is not available, request timed out after 500ms" is what Hikari would say)
* mode "gate" : admission control - at most 20 running + 100 waiting; everything else gets an immediate 503
*/
publicclassGateLab {
publicstaticvoidmain(String[] a) throws Exception {
boolean gate = a[0].equals("gate");
Semaphore connections = newSemaphore(20, true); // the connection poolSemaphore admission = newSemaphore(20 + 100); // admission control (running + waiting)int rate = 3000, seconds = 3, burst = rate * seconds;
AtomicInteger ok = newAtomicInteger(), timeout = newAtomicInteger(), rejected = newAtomicInteger();
long[] okLat = newlong[burst]; AtomicInteger k = newAtomicInteger();
long t0 = System.nanoTime();
try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
long interval = 1_000_000_000L / rate, start = System.nanoTime();
for (int i = 0; i < burst; i++) {
long intended = start + i * interval, now;
while ((now = System.nanoTime()) < intended) { if (intended - now > 300_000) java.util.concurrent.locks.LockSupport.parkNanos(intended - now - 200_000); }
ex.submit(() -> {
long begin = System.nanoTime();
if (gate && !admission.tryAcquire()) { rejected.incrementAndGet(); returnnull; } // fast 503
try {
if (!connections.tryAcquire(500, TimeUnit.MILLISECONDS)) { timeout.incrementAndGet(); returnnull; }
try { Thread.sleep(20); } finally { connections.release(); }
ok.incrementAndGet();
okLat[k.getAndIncrement()] = System.nanoTime() - begin;
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
finally { if (gate) admission.release(); }
returnnull;
});
}
}
long wall = (System.nanoTime() - t0) / 1_000_000;
long[] s = Arrays.copyOf(okLat, k.get()); Arrays.sort(s);
System.out.printf("%-7s offered=%d ok=%d timeout(500ms)=%d rejected-fast=%d wall=%d ms ok-latency p50=%.0fms p99=%.0fms%n",
gate ? "gate" : "nogate", burst, ok.get(), timeout.get(), rejected.get(), wall,
s[s.length / 2] / 1e6, s[(int) (s.length * 0.99) - 1] / 1e6);
}
}
Things to try next
Change CALL_MS to 10 and re-derive the pool size with Little's Law before running; then to 500 and see why a 200-thread Tomcat cannot serve 1,000 req/s.
Run PoolLab with -XX:ActiveProcessorCount=2: the platform-pool result does not move (I/O-bound), the CpuLab result does.
Add a Semaphore(10) around the 50 ms calls in PoolLab virtual (a stand-in for a 10-connection Hikari pool) and watch L stop at 10 and latency grow - the bounded resource, not the threads, is now your pool.
13
Chapter Thirteen - Interview Angles
Concurrency and Pool Questions You Should Answer with Numbers
Q: How many threads should a Tomcat/Spring Boot service use? Start from Little's Law: threads ≥ throughput x time-in-service (1,000/s x 0.15 s = 150). Add headroom (~30%). CPU-bound services need about cores + 1; I/O-bound need many more. Then check the downstream pools (Hikari, HTTP client) support the same concurrency - the smallest pool in the chain sets throughput.
Q: CPU is at 10% but latency is high and throughput is flat. What do you check? Pool saturation and queueing: busy-thread and queue-depth metrics, then thread dumps. Most threads in Socket.read = waiting on a downstream; most BLOCKED on one monitor address = lock contention; all workers busy with a growing queue = pool too small for lambda x W.
Q: Why is Executors.newFixedThreadPool dangerous? Its queue is an unbounded LinkedBlockingQueue. Overload never produces rejection; it produces unbounded latency and eventually OutOfMemoryError. Build ThreadPoolExecutor with a bounded queue and an explicit rejection policy (we saw 16 of 30 tasks rejected under AbortPolicy).
Q: What does the order of ThreadPoolExecutor admission mean in practice? Core threads first, then the queue, then threads up to max, then rejection. So with a large queue "max" is never reached - a common surprise when people raise maxPoolSize to absorb load and nothing changes.
Q: What problem do virtual threads solve, and what do they not solve? They make blocking cheap: one thread per request without an OS-thread limit (10,000 started in 316 ms vs 9116 ms here, throughput 985/s with no tuning). They do not speed up CPU-bound work and do not remove limits of downstream resources like a DB pool.
Q: What is pinning and how do you find it? On JDK 21 a virtual thread that blocks inside synchronized (or a native frame) keeps its carrier. With parallelism 4 we measured 400 x 50 ms taking 5073 ms instead of 87 ms. Find it with -Djdk.tracePinnedThreads=full, JFR jdk.VirtualThreadPinned, or jcmd Thread.dump_to_file; fix by using ReentrantLock or JDK 24+ (JEP 491).
Q: Why shouldn't you pool virtual threads? The pool bounds concurrency to its size, recreating the problem. Create one per task and bound resource use with a Semaphore / bulkhead in front of the scarce resource.
Q: Is CompletableFuture.supplyAsync(blockingCall) fine? No: it runs on the common ForkJoinPool (cores - 1 threads, 11 here), so 1,200 blocking 50 ms calls took 5537 ms instead of ~50-100 ms with a proper executor. Always pass an executor for blocking work and add timeouts.
Q: When is a lock-free counter better than synchronized? When many threads update the same hot variable. LongAdder scaled with threads in our run while synchronized and even AtomicLong did not. For metrics use it; for multi-field invariants use a short critical section.
Q: Is structured concurrency production-ready? On JDK 21 it is a preview API (JEP 453) requiring --enable-preview; the API can still change. The ideas (cancel siblings on failure, no leaked tasks) can be approximated with executors and timeouts today.
Threads ForkJoinPool.commonPool-worker-* in Socket.read
Dedicated or virtual-thread executor; timeouts
Virtual threads enabled, no gain
Pinning, DB pool limit, or CPU-bound
-Djdk.tracePinnedThreads=full, Hikari pending metric, top -H
ReentrantLock / JDK 24; gate bounded resources
After enabling virtual threads, errors under load
Unbounded concurrency hits bounded resource
Connection timeouts, memory growth
Semaphore / bulkhead per downstream
Memory grows with load, then OOM
Unbounded executor queue
Class histogram shows queue nodes
Bounded queue + AbortPolicy + 503
∑ Concurrency - what to remember
Little's Law (L = lambda x W) tells you how many requests are in flight: 1,000/s x 0.150 s = 150 - measured 148.8.
Throughput of a blocking pool = N / W. Pool 50 gave 329/s, pool 100 gave 660/s, pool 150+ gave ~985/s. Past the knee, more threads buy nothing.
Bound every queue and choose a rejection policy on purpose; unbounded queues convert overload into latency and OOM.
Contention is invisible in CPU graphs. Take thread dumps; look for one locked address with many BLOCKED waiters.
CompletableFuture without an executor uses the common pool (cores - 1): fine for CPU work, wrong for blocking calls (5537 ms vs 98 ms for 400 fan-outs).
Virtual threads (JDK 21) make blocking cheap; they do not help CPU-bound work and need gates in front of bounded resources.
Pinning (synchronized + blocking on JDK 21) makes carriers the bottleneck: 5073 ms vs 87 ms. JEP 491 (JDK 24) removes the synchronized case.
Structured concurrency and scoped values are preview in JDK 21.
Related pages:
Measure First - Little's Law, percentiles, Amdahl and USL.