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.

13
Chapters
9
Runnable labs
150
Threads Little says
JDK 21
Virtual threads GA
01
Chapter One - The Problem

Thread-Per-Request and the Hidden Limit

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)
pool = 50 329 /s p99 20,161 ms pool = 100 660 /s p99 5,090 ms pool = 150 980 /s p99 207 ms pool = 200 985 /s p99 167 ms pool = 800 985 /s p99 173 ms virtual threads 985 /s p99 154 ms Measured on this 12-core laptop-class machine, JDK 21.0.2. Red = saturated: the pool, not the CPU, is the limit.
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
Where one OrderService request spends its 150 ms (measured lab shape: 3 blocking calls, ~0 CPU) platform thread customer 50 ms inventory 50 ms payment 50 ms the thread is BLOCKED in socket read for 99% of the request - it holds a stack, a TLAB, a DB connection slot and does nothing CPU actually used ~0.1-1 ms of real work per call: parse request, build JSON. The rest is waiting for the network. 0 ms 50 ms 100 ms 150 ms Throughput of a pool of N threads = N / 0.150 s. N = 50 -> 333 req/s (measured: 329/s). Add CPUs and nothing changes - the limit is the number of threads that can be parked inside the call at once.
Little's Law: how many threads does 1,000 req/s at 150 ms need?
Little's Law applied to a thread pool: L = lambda x W lambda (arrival rate) 1,000 req/s x W (time in service) 0.150 s = L (requests in flight) 150 threads measured: 148.8 pool = 50: needs 150 but has 50 -> 100 requests are always waiting in the queue; the queue grows without bound at 1000-333 = 667 req/s (measured: 6,702 queued at the end, average latency 10,289 ms) pool = 150: exactly at the edge (utilisation ~100%): any jitter queues -> p99 207 ms (measured) pool = 200+: headroom; latency = pure service time 153 ms. Bigger pools buy nothing more (800 threads: same p99).
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)
platform:50 offered=1000/s served=329/s avg=10289ms p50=10322ms p95=19357ms p99=20161ms max=20362ms maxQueue=6702 L=49.7 platform:200 offered=1000/s served=985/s avg=153ms p50=151ms p95=158ms p99=167ms max=186ms maxQueue=13 L=148.8 platform:800 offered=1000/s served=985/s avg=153ms p50=151ms p95=161ms p99=173ms max=196ms maxQueue=11 L=149.3 virtual offered=1000/s served=985/s avg=151ms p50=150ms p95=152ms p99=154ms max=205ms maxQueue=0 L=147.9 platform:100 offered=1000/s served=660/s avg=2640ms p50=2618ms p95=4881ms p99=5090ms max=5142ms maxQueue=3320 L=99.2 platform:150 offered=1000/s served=980/s avg=176ms p50=179ms p95=203ms p99=207ms max=227ms maxQueue=59 L=147.7 platform:170 offered=1000/s served=985/s avg=152ms p50=150ms p95=155ms p99=168ms max=188ms maxQueue=24 L=148.5
PoolServed /sAvg msp95 msp99 msMax queueL (in service)Verdict
503291028919357201616,70249.7saturated
1006602640488150903,32099.2saturated
15098017620320759147.7knife edge
17098515215516824148.5
20098515315816713148.8good
80098515316117311149.3no extra gain
virtual9851511521540147.9chapter 7
Hockey stick: p99 latency vs pool size (offered load 1,000/s, log scale)
100 1000 10000 0 1 2 3 4 5 pool size: 50 100 150 170 200 800 p99 ms p99 latency (ms) Cliff between 100 and 150 threads = where capacity (N / 0.15 s) crosses the offered 1,000 req/s.

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
ThreadPoolExecutor(core=4, max=4, queue=ArrayBlockingQueue(10)) receiving 30 tasks at once execute(task) 1. workers < core (4)? start a new worker thread 2. else queue.offer(task) bounded queue: 10 slots 3. queue full: workers < max? else REJECT 4 tasks run 10 tasks wait 16 tasks rejected AbortPolicy (default): RejectedExecutionException -> caller sees the overload immediately (measured: submit loop 11 ms) CallerRunsPolicy: the submitting thread runs the task itself -> natural backpressure (measured: submit loop slowed to 805 ms) DiscardPolicy: silently drops 16 tasks (measured: executed=14, rejected=0 exceptions) - almost never what you want Trap: with an UNBOUNDED queue (Executors.newFixedThreadPool) step 3 never happens, "max" is ignored, and overload shows up as latency + heap growth instead of an error.
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}
PolicyExecutedRejectedSubmit loopBehaviourUse when
AbortPolicy (default)1416 (exception)11 msRejectedExecutionException, fast failureYou can translate it to HTTP 503 / 429 and shed load
CallerRunsPolicy300805 msSubmitting (e.g. Tomcat) thread runs the task; submission slows downInternal queues where slowing the producer is the right answer
DiscardPolicy140 (silent loss!)3 msDrops the new task without any signalAlmost never; only for droppable telemetry
DiscardOldestPolicy---Drops the head of the queue and retries the new oneLatest-wins data (not run here)
Queue types - what each one means for overload
QueueCapacityEffectWhere you meet it
LinkedBlockingQueue (unbounded)Integer.MAX_VALUEMax-pool-size is never used; overload becomes memory growth and latencyExecutors.newFixedThreadPool, newSingleThreadExecutor (the queue length was 6,702 in chapter 3)
SynchronousQueue0 (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 LinkedBlockingQueueyou chooseOverload is visible: after the queue fills, threads grow to max, then rejectionCustom pools; the recommended default
Spring ThreadPoolTaskExecutorqueueCapacityBoot'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
static final Object CACHE_LOCK = new Object(); static void refresh() throws InterruptedException { synchronized (CACHE_LOCK) { // every request serialises here Thread.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)
threads synchronized ReentrantLock AtomicLong LongAdder (million increments / second) 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
Lab result: one shared counter, million increments per second vs number of threads
144 288 432 576 0 1 2 3 4 threads: 1 thr 2 thr 4 thr 8 thr 12 thr M incr/s synchronized ReentrantLock AtomicLong LongAdder Same hardware, same loop; only the synchronisation primitive changes. Shared host: treat as shape, not as absolute numbers.
ThreadssynchronizedReentrantLockAtomicLong (CAS)LongAdder (striped)
129.242.594.281.5
220.67.629.1132.4
410.121.018.4282.1
86.823.825.3520.0
128.425.226.5576.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
commonPool (default) 5537 ms dedicated fixed pool of 64 1023 ms dedicated fixed pool of 1200 279 ms virtual-thread executor 98 ms Ideal = 50 ms if every call had its own thread. 1,200 calls / 11 common-pool threads x 50 ms = ~5,450 ms (measured 5,537).
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
Virtual threads on carrier threads (JDK 21) virtual threads vthread #1 vthread #2 vthread #3 vthread #4 vthread #5 vthread #6 millions possible (heap objects) ForkJoinPool scheduler = carrier threads (default parallelism = cores = 12) carrier 1 (OS thread) carrier 2 (OS thread) carrier 3 (OS thread) ... up to 12 mounted: running Java code vthread #3 calls socket read / sleep / lock: 1. continuation yields, stack copied to a heap "stack chunk" 2. carrier is FREE immediately and picks another vthread I/O ready / timer fires: 3. vthread is scheduled again (FIFO), 4. stack chunk is thawed onto ANY free carrier Pinned = cannot unmount: blocking inside synchronized (JDK 21) or with a native frame on the stack. The carrier stays blocked with it. JEP 491 (JDK 24) removes the synchronized case.
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).
6 Verification
▶
Table and charts below.
Lab result: p99 latency (ms) at 1,000 req/s - blocking workload
platform pool = 200 167 ms platform pool = 800 173 ms virtual threads 154 ms Virtual threads need no pool tuning: no queue (max queue = 0), L = 147.9 tracks lambda x W.
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
10,000 platform threads 9116 ms RSS 131 MB 10,000 virtual threads 316 ms RSS 90 MB 100,000 virtual threads 688 ms RSS 250 MB Log-scaled bars. RSS read from /proc/self/status while all threads are blocked (baseline JVM 39 MB). Shared, loaded host: the platform start time is inflated.
Platform threadVirtual thread
Scheduled byOS kernelJVM (ForkJoinPool in FIFO mode; parallelism = cores by default)
StackFixed reservation (ThreadStackSize 1024 KB default), committed as touchedGrows/shrinks on the heap as stack chunks while unmounted
CreationExpensive (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?ConfigurableAlways daemon
Good forCPU-bound, long-lived, native callsBlocking I/O fan-out, thread-per-request servers
Blocking synchronizedNormalPins the carrier on JDK 21; mostly fixed in JDK 24 (JEP 491)
Thread dumpjstack / jcmd Thread.printjcmd PID Thread.dump_to_file -format=text|json FILE (Thread.print lists carriers only)
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)
platform pool = 4 8207 /s range 7380-9129 platform pool = 12 (=cores) 16547 /s range 15312-16608 platform pool = 200 20458 /s range 20355-21247 virtual thread per task 16018 /s range 10304-17498 96,000 tasks of 8 x SHA-256 over 64 KB (~0.5 ms each). 12 cores, shared host. 4 -> 12 threads doubles throughput; beyond the core count there is no multiple.
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)
synchronized, 4 carriers 5073 ms pinned ReentrantLock, 4 carriers 87 ms synchronized, 12 carriers 1731 ms pinned ReentrantLock, 12 carriers 118 ms Ideal (no pinning) is ~50 ms. Pinned carriers = parallelism: 400 x 50 / 4 = 5,000 ms; 400 x 50 / 12 = 1,667 ms.
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)
#179 "" virtual java.base/jdk.internal.misc.Unsafe.park(Native Method) java.base/java.lang.VirtualThread.parkOnCarrierThread(VirtualThread.java:663) java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:634) 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) java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
Real output - the same dump for the ReentrantLock variant: the virtual thread is simply parked (unmounted)
#20 "" virtual 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:38) PinLab.lambda$main$0(PinLab.java:28) java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317) java.base/java.lang.VirtualThread.run(VirtualThread.java:309)
QuestionAnswer (JDK 21)Answer (JDK 24+)
Does blocking inside synchronized pin the carrier?YesNo - JEP 491 (JDK 24) lets virtual threads unmount while holding a monitor
Object.wait() inside synchronizedPinsFixed by JEP 491
Native (JNI) frame or foreign call on the stackPinsStill pins
ReentrantLock, Semaphore, BlockingQueueFine (unmount)Fine
Class initialisers (<clinit>) blockingPinsStill 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
Admission control in front of a bounded resource
Overload (3,000 req/s offered, capacity 1,000/s): where the 9,000 requests end up no gate ok 3,205 timeout after 500 ms 5,795 successful requests: p50 519 ms, p99 533 ms with gate ok 2,918 rejected fast 6,082 successful requests: p50 123 ms, p99 139 ms No gate: 5,795 requests each held a virtual thread, a request context and a client connection for 500 ms and then failed anyway. Gate: the same ~3,000 requests succeeded at a quarter of the latency, and the rest got an instant, cheap 503.
Java - the gate (the whole idea in a few lines)
Semaphore connections = new Semaphore(20, true); // stands for Hikari maximumPoolSize Semaphore admission = new Semaphore(20 + 100); // running + tolerated waiting if (!admission.tryAcquire()) return Response.status(503); // shed load: cheap, immediate try { if (!connections.tryAcquire(500, TimeUnit.MILLISECONDS)) return Response.status(503); try { return runQuery(); } finally { connections.release(); } } finally { admission.release(); }
Where to put the gate in a real OrderService
  • 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 21 Structured 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 = new StructuredTaskScope.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 interrupted return cust.get() + inv.get() + pay.get(); }
CompletableFutureStructuredTaskScope (preview)
Failure of one branchSiblings keep running unless you cancel them by handShutdownOnFailure interrupts siblings
LifetimeFutures can outlive the method that started them (leaks)Subtasks cannot outlive the try block
Thread dump / observabilityTasks have no parent linkSubtasks are children of the owner thread in Thread.dump_to_file JSON
Runs onAny executorOne virtual thread per subtask
Status in JDK 21StablePreview (--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 ~0 CPU. Requests ARRIVE at a fixed rate (open model), so a saturated pool * shows up as queueing delay, exactly as in production. * * java PoolLab platform:200 1000 10 -> fixed pool of 200 platform threads, 1000 req/s for 10 s * java PoolLab virtual 1000 10 -> one virtual thread per request */ public class PoolLab { static final int CALLS = 3, CALL_MS = 50; static final AtomicInteger inFlight = new AtomicInteger(); static final LongAdder inFlightSamples = new LongAdder(), inFlightSum = new LongAdder(); public static void main(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 = new long[total]; AtomicInteger done = new AtomicInteger(); CountDownLatch all = new CountDownLatch(total); AtomicInteger maxQueue = new AtomicInteger(); // sampler for L (average requests inside the service) and queue length ScheduledExecutorService sampler = Executors.newSingleThreadScheduledExecutor(); final ThreadPoolExecutor 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); } final int 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); double W = 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())); } static double p(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 (JDK 21: blocking inside synchronized pins the carrier; fixed in JDK 24, JEP 491). * 400 virtual threads each hold THEIR OWN 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=4 PinLab sync * java -Djdk.virtualThreadScheduler.parallelism=4 -Djdk.virtualThreadScheduler.maxPoolSize=4 PinLab lock */ public class PinLab { public static void main(String[] a) throws Exception { boolean useSync = a[0].equals("sync"); long ms = a.length > 1 ? Long.parseLong(a[1]) : 50; // blocking time per task int n = 400; Object[] monitors = new Object[n]; ReentrantLock[] locks = new ReentrantLock[n]; for (int i = 0; i < n; i++) { monitors[i] = new Object(); locks[i] = new ReentrantLock(); } long t0 = System.nanoTime(); try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < n; i++) { final int 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(); } } return null; }); } } 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); } static void sleep(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 for 3 s (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 */ public class GateLab { public static void main(String[] a) throws Exception { boolean gate = a[0].equals("gate"); Semaphore connections = new Semaphore(20, true); // the connection pool Semaphore admission = new Semaphore(20 + 100); // admission control (running + waiting) int rate = 3000, seconds = 3, burst = rate * seconds; AtomicInteger ok = new AtomicInteger(), timeout = new AtomicInteger(), rejected = new AtomicInteger(); long[] okLat = new long[burst]; AtomicInteger k = new AtomicInteger(); 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(); return null; } // fast 503 try { if (!connections.tryAcquire(500, TimeUnit.MILLISECONDS)) { timeout.incrementAndGet(); return null; } 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(); } return null; }); } } 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.
14
Chapter Fourteen - Summary

Cheat Sheet and Recap

Cheat sheet
SymptomLikely causeEvidence / toolFix
Low CPU, flat throughput, rising latencyPool smaller than lambda x WBusy threads = max, queue growing, L = pool sizeResize from Little; bound the queue; reject early
Many threads BLOCKED on one addressLock contention / long critical sectionjcmd Thread.print x3, JFR jdk.JavaMonitorEnterShrink critical section, concurrent structure, LongAdder
Fan-out slower than the slowest callBlocking work on commonPoolThreads ForkJoinPool.commonPool-worker-* in Socket.readDedicated or virtual-thread executor; timeouts
Virtual threads enabled, no gainPinning, DB pool limit, or CPU-bound-Djdk.tracePinnedThreads=full, Hikari pending metric, top -HReentrantLock / JDK 24; gate bounded resources
After enabling virtual threads, errors under loadUnbounded concurrency hits bounded resourceConnection timeouts, memory growthSemaphore / bulkhead per downstream
Memory grows with load, then OOMUnbounded executor queueClass histogram shows queue nodesBounded 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: