Project Loom promised a simple deal: write plain blocking code, run millions of virtual threads, and let the JVM do the multiplexing. Most of the time it delivers. But there is one failure mode that quietly cancels the deal and gives you back a bounded thread pool without telling you — pinning. Your throughput graph flattens, latency climbs under load, and the code looks perfectly innocent. This is the one Loom problem you cannot see by reading the source; you have to look at what the carriers are doing.
This article explains what pinning is, exactly why it happens, what JDK 24 changed, and how to detect and fix it — including the case that still bites on the newest JVMs.
What virtual thread pinning is
A virtual thread does not have its own OS thread. It runs on a small pool of platform threads called carrier threads. When a virtual thread blocks — on I/O, on a lock, on a queue — it normally unmounts: it saves its stack, steps off the carrier, and frees that carrier to run another virtual thread. That unmount is the whole trick that lets a handful of OS threads serve millions of virtual ones.
Pinning is when that unmount cannot happen. The virtual thread blocks but stays mounted, so its carrier is stuck doing nothing useful for the duration. One pinned carrier is a minor waste. But the default carrier pool has only as many threads as you have CPU cores, so if a hot code path pins routinely, you can pin every carrier at once — and then no virtual thread anywhere can make progress. That is not a slowdown; that is a scheduler starvation that can look exactly like a deadlock.
Carrier threads: mount and unmount
The carrier pool is a dedicated ForkJoinPool whose worker threads live in a thread group named CarrierThreads. Their default parallelism equals Runtime.availableProcessors(). You can widen it with -Djdk.virtualThreadScheduler.parallelism=N, but that only raises the ceiling — it does not stop pinning, it just delays the moment every carrier is exhausted.
The mount/unmount bookkeeping is what makes blocking cheap. Anything that prevents the JVM from safely unmounting a virtual thread mid-block forces it to keep the carrier — and that is pinning by definition.
Why pinning happens
There are exactly two situations where the JVM cannot unmount a blocked virtual thread:
- Blocking inside
synchronized. Up to and including JDK 23, an object monitor is associated with the carrier thread that entered it. If a virtual thread blocks (or callsObject.wait()) while holding a monitor, the JVM cannot move it off the carrier without breaking monitor ownership — so it pins. This is by far the most common cause in real code, because a blocking call buried inside asynchronizedmethod is easy to write and invisible at the call site. - Native methods and foreign functions. When a virtual thread is executing a native (JNI) method or a foreign downcall (the Foreign Function & Memory API, formerly Panama) and it blocks, the JVM has a native stack frame it cannot capture and restore — so it pins. This one has no
synchronizedto blame and is not fixed by JDK 24.
Note what is not on this list: ordinary blocking I/O through the JDK (Socket, InputStream, Files), BlockingQueue, ReentrantLock, CompletableFuture,Thread.sleep() — all of these were re-plumbed for Loom and unmount cleanly. Pinning is a short, specific list, which is exactly why it is detectable.
What JDK 24 changed (JEP 491)
JDK 24 shipped JEP 491, "Synchronize Virtual Threads without Pinning." It reworked how the JVM tracks monitor ownership so that a virtual thread can unmount while blocked inside a synchronized method or block, and while parked in Object.wait(). In other words, the first and most common cause of pinning simply goes away when you run on JDK 24 or later — with no code change.
Two practical consequences. First, on JDK 24+ the only remaining pinning cause is native / foreign-function frames. Second, the old detection flag -Djdk.tracePinnedThreads was removed: it no longer prints anything, because the case it was built to catch no longer pins. If you are on JDK 21–23, pinning from synchronized is very real and upgrading is often the single cleanest fix.
How to detect pinning
The legacy flag: jdk.tracePinnedThreads (JDK 21–23)
On JDK 21 through 23, add the system property and the JVM prints a stack trace every time a virtual thread pins:
java -Djdk.tracePinnedThreads=full -jar app.jar # use =short for a trimmed trace that only highlights the offending frames
The output points straight at the frame that caused the pin — the line annotated with <== monitors:1 is the one holding the monitor:
Thread[#31,ForkJoinPool-1-worker-1,5,CarrierThreads]
java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:...)
java.base/java.lang.VirtualThread.parkOnCarrierThread(VirtualThread.java:...)
app//com.example.PrecoService.buscar(PrecoService.java:42) <== monitors:1
app//com.example.PrecoController.cotar(PrecoController.java:19)That single <== monitors:1 line is the whole diagnosis: line 42 of PrecoService blocked while holding a monitor.
The forward-compatible way: JFR
Since JDK 21 the JVM emits a JDK Flight Recorder event, jdk.VirtualThreadPinned, for every pinning occurrence, with its duration and stack. Because this event survives JDK 24 (native/FFM pins still fire it), it is the detection you should wire into production:
java -XX:StartFlightRecording=filename=rec.jfr,settings=profile -jar app.jar # then inspect: jfr print --events jdk.VirtualThreadPinned rec.jfr
A related event, jdk.VirtualThreadSubmitFailed, tells you when the scheduler could not even hand a virtual thread to a carrier — often the downstream symptom of chronic pinning.
From a thread dump
A virtual-thread-aware thread dump — jcmd <pid> Thread.dump_to_file -format=json dump.json — lists the carrier threads and the virtual thread mounted on each. A carrier in the CarrierThreads group that is blocked while a mounted virtual thread sits in a synchronized frame (or a native frame) is the visual signature of a pin.
Example: synchronized around blocking I/O
The canonical pinning bug is a cache or a rate limiter that guards a slow call with synchronized:
public class PrecoService {
private final Map<String, BigDecimal> cache = new HashMap<>();
// Looks harmless. On JDK 21-23 it pins the carrier for the whole HTTP call.
public synchronized BigDecimal buscar(String simbolo) {
return cache.computeIfAbsent(simbolo, s -> {
// blocking HTTP call to an external quote API
return clienteHttp.cotar(s); // <-- blocks while holding the monitor
});
}
}Every call to buscar that misses the cache blocks on the network while holding the monitor. On JDK 21–23 that virtual thread pins its carrier for the entire round trip. Run a few hundred concurrent requests and you pin every carrier at once; the service that was supposed to scale to millions of virtual threads now serves one request per CPU core, and the rest queue behind a monitor that never unmounts.
How to fix pinning
- Swap
synchronizedforReentrantLock.java.util.concurrent.locks.ReentrantLockis Loom-aware: a virtual thread that blocks while holding it unmounts cleanly. This is the direct, version-independent fix. - Upgrade to JDK 24+. JEP 491 removes the
synchronizedpin entirely, so the example above stops pinning without any code change. Native / FFM pins remain. - Move the blocking work out of the guarded section. Often the cleanest answer is to not hold a lock across an external call at all — compute the value outside the critical section and only lock the map update.
- For native / foreign-function pins, isolate the path. If a blocking JNI or FFM call is unavoidable, keep it off the hot virtual-thread path (run it on a dedicated platform-thread executor) or size the carrier pool with
jdk.virtualThreadScheduler.parallelismso a few concurrent pins do not starve everything.
The rewrite is small — but there is one trap. The monitor that pins you does not have to be your own: don't just move the blocking call into ConcurrentHashMap.computeIfAbsent, because its mapping function runs under the map's internal synchronized bin lock — on JDK 21–23 you would have rebuilt the exact same pin one layer down. Compute the value outside the map and only lock the read-check-and-update:
public class PrecoService {
private final Map<String, BigDecimal> cache = new ConcurrentHashMap<>();
private final ReentrantLock lock = new ReentrantLock();
public BigDecimal buscar(String simbolo) {
BigDecimal cached = cache.get(simbolo);
if (cached != null) return cached;
lock.lock(); // Loom-aware: unmounts if it blocks
try {
cached = cache.get(simbolo); // re-check under the lock
if (cached != null) return cached;
BigDecimal cotacao = clienteHttp.cotar(simbolo); // blocks; carrier is freed
cache.put(simbolo, cotacao);
return cotacao;
} finally {
lock.unlock();
}
}
}How ThreadMine detects pinning automatically
Pinning is invisible in ordinary metrics, so ThreadMine looks for its signature directly in the dump. When a virtual-thread dump comes in, it identifies the carrier threads (the ForkJoinPool workers in the CarrierThreads group) and inspects what is mounted on each. A carrier that is blocked while its mounted virtual thread holds a monitor inside a synchronized frame — or is parked inside a native / foreign frame — is flagged as a pin, with the exact frame that caused it.
Each pinned carrier becomes a card with the mounted virtual thread's stack and the offending frame highlighted, and ThreadMine surfaces the aggregate: how many carriers are pinned right now versus the carrier-pool size, which is the number that tells you whether you are one bad path away from full scheduler starvation. It is the same read you would do by hand from jdk.tracePinnedThreads or a jdk.VirtualThreadPinned JFR event — done in seconds, on every dump, without you having to remember to turn a flag on first.
Conclusion
Pinning is the one Loom failure mode that turns your scalability story back into a bounded pool without a single error in the logs. The rules are short: know that only synchronized (pre-JDK 24) and native / foreign frames pin; detect it with jdk.tracePinnedThreads on older JDKs and the jdk.VirtualThreadPinned JFR event everywhere; fix it by moving to ReentrantLock or upgrading to JDK 24. And when the dumps pile up, let the reading be automated.
Frequently asked questions
What is virtual thread pinning?
Pinning is when a virtual thread cannot unmount from its carrier — the platform thread that runs it — while it blocks. Normally a blocking virtual thread releases its carrier so another virtual thread can run there; a pinned one holds the carrier hostage. If enough carriers are pinned at once, the whole scheduler starves and you lose the scalability Loom was supposed to give you.
What causes pinning in Project Loom?
Two things, historically. Blocking while inside a synchronized method or block, because the object monitor was tied to the carrier thread; and running a native method (JNI) or a foreign function (FFM / Panama downcall) that blocks. JDK 24 (JEP 491) removed the synchronized case, so on modern JDKs only native and foreign-function frames still pin.
Does synchronized still pin virtual threads?
Not on JDK 24 or later. JEP 491 reworked monitor ownership so a virtual thread can unmount while blocked inside synchronized or in Object.wait(). On JDK 21 through 23 synchronized around a blocking call is the single most common cause of pinning, so upgrading is often the fastest fix.
How do I detect pinning in a running JVM?
On JDK 21 to 23, run with -Djdk.tracePinnedThreads=full and the JVM prints a stack trace each time a virtual thread pins, marking the offending frame with <== monitors:N. That flag was removed in JDK 24, so the forward-compatible way is the JFR event jdk.VirtualThreadPinned, which records every pinning event and its duration. A virtual-thread thread dump also shows carrier threads and what is mounted on them.
How do I fix pinning?
Replace synchronized with a java.util.concurrent.locks.ReentrantLock around the blocking section — ReentrantLock is Loom-aware and lets the virtual thread unmount. Or upgrade to JDK 24+, which fixes the synchronized case for you. Native and foreign-function pinning has no code-level fix beyond avoiding the blocking native path on hot virtual threads or sizing the carrier pool for it.
Want to see all this applied to your own dump?
Upload a dump and get a health score, detected problems and fixes in seconds. No signup for the first dump.