Pinning is when a virtual thread blocks but cannot unmount from its carrier, so the carrier stays occupied instead of running other virtual threads. If enough carriers are pinned at once, throughput collapses or the app can even deadlock for lack of available carriers.
What causes it
Two classic causes:
synchronizedblocks/methods — historically, blocking inside asynchronizedregion held the carrier because the monitor was tied to the native carrier frame.- Native frames — when there is a native (JNI) method or a foreign-function frame on the stack at the blocking point, the continuation cannot be unmounted.
In these cases the virtual thread blocks the OS-level carrier the same way a platform thread would, defeating the purpose of Loom.
private final Object lock = new Object();
// Risky on Java 21: blocking inside synchronized pins the carrier
synchronized (lock) {
response = httpClient.send(request, ...); // pins
}
// Preferred: ReentrantLock unmounts cleanly while blocked
lock.lock();
try {
response = httpClient.send(request, ...); // unmounts, carrier freed
} finally {
lock.unlock();
}
The Java 24 fix
In Java 21–23 the standard advice was: replace synchronized around blocking I/O with ReentrantLock, and detect pinning with -Djdk.tracePinnedThreads=full. JEP 491 (Java 24) largely eliminated the synchronized pinning problem by reworking monitors so a virtual thread can unmount while holding one. After Java 24, pinning from synchronized is no longer the common trap.
Why it hurts and how to spot it
The danger is silent: code runs correctly but loses scalability, because your "millions of threads" are bottlenecked on a tiny pool of pinned carriers. Symptoms are surprisingly low throughput and carriers stuck in synchronized/native frames. Detect with jdk.tracePinnedThreads, JFR's jdk.VirtualThreadPinned event, or a thread dump showing carriers parked inside monitors.