Virtual Threads Made It 41x Faster - Until One synchronized Made It 25x Slower
I moved a blocking workload to virtual threads and it got 41x faster. Then one synchronized block made it 25x slower than the thread pool I replaced - and made an unrelated 1ms task take 7.8 seconds. Measured on JDK 21 with real sockets, including which mitigation works and which widely-repeated one does nothing.
I moved a blocking workload from a thread pool to virtual threads and it got 41× faster. Then I wrapped the blocking call in a synchronized block — no other change — and it became 25× slower than the thread pool I had just replaced.
Same work, same machine, same JDK:
java 21.0.3 | cores 8 | tasks 10000 | block 100ms
platform pool of 200, plain sleep 5373 ms 1861 tasks/s
virtual threads, plain sleep 130 ms 76923 tasks/s
virtual threads, sleep inside synchronized 133372 ms 75 tasks/s
virtual threads, sleep inside ReentrantLock 125 ms 80000 tasks/s
That third line is worth understanding, because nothing about the code looks wrong.
Everything below is measured on one machine — 8 cores, JDK 21.0.3 — and every benchmark is published, so the numbers can be checked on other hardware rather than taken on trust.
What's being measured
Ten thousand tasks, each blocking for 100 ms. The only variable is how the blocking is wrapped:
/** Each task owns its lock, so nothing contends — any slowdown is pinning, not waiting. */
static void submitSynchronized(ExecutorService ex) {
for (int i = 0; i < TASKS; i++) {
final Object lock = new Object();
ex.submit(() -> { synchronized (lock) { block(); } return null; });
}
}
Note the comment. Every task locks its own private object. There is zero contention — no task ever waits for another to release anything. If locks were the problem, this code would have none.
"But that's Thread.sleep, not real I/O"
Fair, and the first thing I'd say too. So I ran it again against a real TCP server on localhost that delays 100 ms before replying — actual connect, write, and a blocking read:
java 21.0.3 | cores 8 | tasks 1000 | server delay 100ms | REAL SOCKET I/O
platform pool of 200, socket read 658 ms 1520 req/s
virtual threads, socket read 335 ms 2985 req/s
virtual threads, socket read in synchronized 13565 ms 74 req/s
Same shape. The pinned case predicts 1000 ÷ 8 carriers × 100 ms = 12.5 s; measured 13.6 s.
One aside from building that harness, because it cost me a five-minute timeout: my first version ran the server on virtual threads too. The pinned client starved the server, since both drew from the same carrier pool. The server had to be moved to platform threads to measure the client at all. Keep that in mind — it comes back later.
Why one keyword undoes everything
A virtual thread runs on a carrier — a real OS thread from a ForkJoinPool. When it blocks, the JVM unmounts it, and the carrier picks up another virtual thread. That unmounting is the whole trick.
In JDK 21, a virtual thread cannot unmount while it holds a monitor. synchronized acquires a monitor. So a virtual thread blocking inside synchronized keeps its carrier — parked and useless — for the entire duration.
Effective concurrency collapses from "however many virtual threads were created" to "however many carriers exist," which defaults to the core count.
The arithmetic confirms it: 10,000 × 100 ms ÷ 8 = 125 seconds. Measured 133.
And the arithmetic needn't be taken on faith, because the JDK says it outright:
java -Djdk.tracePinnedThreads=short Pin
Thread[#24,ForkJoinPool-1-worker-2,5,CarrierThreads]
Pin.lambda$main$0(Pin.java:8) <== monitors:1
monitors:1, on a thread from CarrierThreads. That is a carrier held hostage.
Proving it's pinning and not just locking
"Locks are slow" is the lazy explanation, so I crossed lock type against sharing. Four combinations, 400 tasks, 50 ms of blocking each:
| private lock (no contention) | shared lock (full contention) | |
|---|---|---|
synchronized | 2,906 ms | 23,139 ms |
ReentrantLock | 63 ms | 22,990 ms |
Read the columns.
With private locks, nothing is contended — yet synchronized costs 46× more than ReentrantLock. That gap is pure pinning; there is nothing else it could be.
With a shared lock, the two are identical (23,139 vs 22,990). When there's real contention everything serialises and the lock type stops mattering — 400 × 50 ms ≈ 20 s either way.
ReentrantLock is built on AbstractQueuedSynchronizer, which is virtual-thread-aware and releases the carrier properly. synchronized, in JDK 21, is not.
The shape that actually transfers
Absolute milliseconds from my laptop transfer to nobody. The scaling shape does:
Virtual threads are flat — 61 ms at 100 tasks, 55 ms at 4,000. Concurrency scales with the number of tasks, so more tasks cost roughly nothing until something real saturates.
The other two lines are straight on a log-log plot, meaning linear: the pool at n/200, the pinned case at n/8.
This is why the pinning penalty isn't a fixed multiplier. At 100 tasks it's 12×. At 4,000 it's 523×. It gets worse exactly as load increases — which is to say, in production, at the worst possible moment.
The part that actually worries me
Everything so far measures the pinning code's own throughput. But carriers are a JVM-wide resource.
I ran a trivial 1 ms task while different background loads were running:
A task that shares nothing with the background work — different code, different objects, no locks — takes 7.8 seconds instead of 2 ms. Not because it's slow, but because there was no carrier to run it on.
This is the real hazard. One endpoint that wraps a database call in synchronized doesn't just make itself slow; it adds seconds of latency to every unrelated request in the JVM. The health check times out. Metrics stop reporting. The dashboard implicates the wrong service entirely, because the slow endpoint may not even be the one being hammered.
That's also the five-minute timeout I hit earlier, in miniature.
The mitigation everyone repeats, measured
The common advice is to raise the scheduler's pool size. So I did:
maxPoolSize=default(8) 27003 ms
maxPoolSize=64 26997 ms
maxPoolSize=256 27003 ms
No effect whatsoever. Not a small one — none.
jdk.virtualThreadScheduler.maxPoolSize caps how far the pool may temporarily expand to compensate for blocking the scheduler can detect. A carrier pinned by a monitor isn't in that category, so no compensation happens and the cap is never approached.
The property that governs how many carriers actually run concurrently is a different one:
parallelism=default maxPoolSize=default 27026 ms
parallelism=64 maxPoolSize=default 3410 ms
parallelism=64 maxPoolSize=64 3431 ms
parallelism=64 gives 7.9× — almost exactly the 8× predicted by going from 8 carriers to 64.
Worth being precise about, because "bump maxPoolSize" circulates as though it works. It does not. And parallelism is only mitigation: it buys concurrency with OS threads, which is the exact cost virtual threads existed to remove. That is the thread pool again, with extra steps.
What actually fixes it
A ReentrantLock. Fastest result in the benchmark — 125 ms, indistinguishable from no lock at all:
final ReentrantLock lock = new ReentrantLock();
lock.lock();
try { block(); } finally { lock.unlock(); }
Slightly more ceremony, and the finally is not optional. That's the whole cost.
Or a newer JDK. JEP 491 landed in JDK 24 and lets synchronized release the carrier like any other blocking point. On 24 or later this stops being interesting. On 21 — the LTS, where a great deal of production Java lives — it is very much still interesting.
Where it hides
My own synchronized blocks are the easy case — I can grep for those.
The dangerous ones are in code I didn't write. Older JDBC drivers, connection pools, HTTP clients, logging appenders and object pools are full of synchronized, often wrapped directly around the I/O. Virtual threads get adopted, throughput gets worse, and nothing in the application code changed.
So I measure instead of guessing:
-Djdk.tracePinnedThreads=shortduring development. It prints a stack trace at each pin, so the offending frame comes for free.- The
jdk.VirtualThreadPinnedJFR event for anything long-running, since the trace flag is far too noisy under real load.
And my cheap smoke test: if virtual threads changed nothing, I suspect pinning before I suspect the feature.
What virtual threads don't do
Worth stating plainly, because the 41× at the top invites the wrong conclusion. The same harness with 2,000 CPU-bound tasks — no blocking at all, about 5 ms of real arithmetic each:
platform pool of 8 1587 ms
platform pool of 200 1574 ms
virtual threads 1622 ms
Identical, within noise — and virtual threads are marginally worst. Of course they are: there are eight cores, the work is CPU-bound, and no amount of cheap threads creates more CPU. Virtual threads make waiting cheap. For a service that is slow because it computes, they do nothing at all, and the honest fix is a profiler.
Caveats
Thread.sleep is not a socket read — which is why I ran the socket version. Localhost is not a network. Ten thousand identical tasks are not production traffic. An 8-core laptop is not a production server, and my absolute numbers belong in nobody's capacity plan.
What transfers is the shape: unpinned scales with tasks, pinned scales with cores, and the gap widens with load. On a 4-core container serving thousands of concurrent requests, it's wider than what I measured, not narrower.
The broader point is that virtual threads are not a flag to be switched on. They change which resource is scarce, and the failure mode is silent — no exception, no warning, just a system mysteriously slower than the one it replaced, and unrelated endpoints degrading alongside it. The fix is nearly always a one-line change. Finding it requires knowing it exists.
All eight benchmarks are on GitHub as loom-pinning-benchmarks — dependency-free, JDK 21+, ./run-all.sh and about five minutes. I would trust a number measured locally over mine.
Leave a comment
No account needed. Leave the name blank and you'll get a random one.