All posts
JavaConcurrencyVirtual ThreadsPerformanceJVM

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.

How a virtual thread unmounts, and why synchronized stops it Blocking normally: a virtual thread unmounts from its carrier so the carrier can run the next one, giving concurrency equal to the number of tasks. Blocking inside a synchronized block: the virtual thread cannot unmount while holding the monitor, so the carrier is held idle and concurrency drops to the number of carriers. blocking normally carrier C1 an OS thread VT 1 VT 2 VT 3 each blocks → unmounts → carrier takes the next one concurrency = tasks blocking inside synchronized carrier C1 held, idle VT 1 monitor VT 2 VT 3 waiting for a free carrier cannot unmount while it holds the monitor concurrency = carriers

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)
synchronized2,906 ms23,139 ms
ReentrantLock63 ms22,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:

Wall time against concurrency for three strategies, log-log Virtual threads stay flat near 60 milliseconds from 100 to 4000 tasks. A 200-thread platform pool rises linearly to 1179 milliseconds. Virtual threads pinned by synchronized rise fastest, reaching 28777 milliseconds at 4000 tasks. wall time vs concurrency — 50ms of blocking per task, 8 cores, log scale a flat line means concurrency scales with tasks; a sloped one means it scales with carriers 10 ms 100 ms 1,000 ms 10,000 ms 100 250 500 1000 2000 4000 tasks in flight 55 ms virtual threads 1,179 ms platform pool of 200 28,777 ms pinned by synchronized

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:

How long a trivial task waits, depending on background work A one millisecond task finishes in 5 milliseconds with no load and 2 milliseconds under a normally blocking load, but takes 7822 milliseconds when background tasks pin the carriers. time for a trivial 1ms task to finish, by what else is running 1 10 100 1,000 10,000 milliseconds (log scale) no background load 5 ms background blocking normally 2 ms background pinning the carriers 7,822 ms The victim task shares nothing with the background work except the scheduler.

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=short during development. It prints a stack trace at each pin, so the offending frame comes for free.
  • The jdk.VirtualThreadPinned JFR 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.

0/2000