Virtual Thread Pinning: The Synchronized Block Trap Nobody Warned You About
Virtual threads are supposed to make blocking cheap. That is the whole promise of Project Loom: keep the simple thread-per-request style, but let the JVM mount and unmount those lightweight threads from a small pool of carrier threads. That works very well when the virtual thread can park freely. It becomes dangerous when the thread blocks while holding a monitor. On Java 21–23, that is the classic pinning trap. On Java 24+, JEP 491 changes the story significantly and removes nearly all synchronized-related pinning cases.
The trap is subtle because the code often looks harmless. A synchronized block around a shared resource feels natural, and a blocking call inside that block feels normal too. But if the virtual thread blocks while it still owns the monitor, the JVM may need to keep the carrier attached so it can preserve monitor ownership. That is carrier pinning. A ReentrantLock, by contrast, usually lets the waiting path park through the Java concurrency machinery instead of monopolizing the carrier in the same way.
What pinning actually means
Pinning does not mean all synchronized code is bad. Pinning means the JVM cannot unmount the virtual thread from its carrier at a moment when it would normally like to do so. On Java 21–23, that most commonly happens when a blocking operation occurs inside a monitor-protected section. The carrier stays occupied, and if enough virtual threads do this at once, the carrier pool becomes the real bottleneck. That is exactly why the scalability benefit of virtual threads can quietly disappear in production.
The important detail is the blocking point, not merely the presence of a lock. Entering synchronized alone is not the whole problem. The expensive moment is when the code blocks while still holding the monitor. In practical terms, that means a Thread.sleep, blocking I/O, or similarly blocking operation inside a synchronized region is where the carrier can get pinned on older runtimes. OpenJDK’s JEP 444 describes the mount/unmount model, and JEP 491 explains why synchronized historically prevented unmounting and how Java 24 changed that.
Small visual guide

synchronized keeps the carrier occupied, while waiting to acquire a ReentrantLock typically releases the carrier back to the scheduler. This is a simplified teaching visual, not a benchmark.
ReentrantLock is usually parked by the concurrency framework instead.A simple rule of thumb
If a virtual thread is only waiting to acquire a ReentrantLock, the wait is generally handled by the Java locking machinery and the carrier is not tied up in the same way. If a virtual thread is blocked inside a synchronized region on Java 21–23, the carrier can be pinned. That is the simple mental model that helps you reason about this problem without getting lost in JVM internals. The official Oracle virtual-thread guide describes the carrier model, and the ReentrantLock API documents the explicit lock semantics that make this path different from intrinsic monitors.
Runnable comparison
Below is a compact program you can run on Java 21+ to compare the two styles. The code is intentionally small so the behavior is obvious. On Java 21–23, the synchronized case is the one that can pin the carrier if the blocking call happens while the monitor is held. On Java 24+, the synchronized case is much less likely to pin because of JEP 491.
<pre class="brush:java"><code>
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.ReentrantLock;
public class VirtualThreadPinningDemo {
private static final Object monitor = new Object();
private static final ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) throws Exception {
CountDownLatch done = new CountDownLatch(2);
Thread.ofVirtual().name("sync-vt").start(() -> {
synchronized (monitor) {
simulateBlockingWork("synchronized");
}
done.countDown();
});
Thread.ofVirtual().name("lock-vt").start(() -> {
lock.lock();
try {
simulateBlockingWork("ReentrantLock");
} finally {
lock.unlock();
}
done.countDown();
});
done.await();
System.out.println("Done");
}
private static void simulateBlockingWork(String label) {
System.out.println(label + " entered by " + Thread.currentThread());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println(label + " leaving by " + Thread.currentThread());
}
}
</code></pre>
How to run it
Compile and run the program with Java 21+:
javac VirtualThreadPinningDemo.java2java VirtualThreadPinningDemo
If you want to observe pinning on Java 21–23, use JFR and inspect the jdk.VirtualThreadPinned event, or enable the pinned-thread trace while testing locally. Oracle’s JFR documentation describes JFR as the low-overhead way to capture runtime events, and the virtual-thread guide treats pinned-thread debugging as part of the normal adoption workflow.
Exactly when the carrier gets pinned
Here is the practical rule: on Java 21–23, the carrier can get pinned when a virtual thread blocks inside a synchronized region or inside some native/foreign call because the JVM must preserve ownership information and cannot safely unmount the virtual thread. In the code above, the carrier is at risk of being pinned during Thread.sleep(500) while the synchronized monitor is still held. That is the real trap: not the lock itself, but the blocking operation inside the lock.
For ReentrantLock, the waiting path is different. A contending thread generally parks through the Java lock framework instead of pinning the carrier in the same way. That is why ReentrantLock became the safer practical recommendation for long waits in Java 21–23. The ReentrantLock API is still a mutual-exclusion lock, but the waiting semantics are more flexible than intrinsic monitors, and that matters a lot when the runtime is trying to keep carriers free for other virtual threads.
The version caveat that many articles skip
This is the part that matters most in 2026: the “avoid synchronized with virtual threads” advice is now version-sensitive. It is accurate for Java 21–23, but not equally accurate for Java 24 and later. JEP 491 was specifically delivered to improve the scalability of synchronized methods and statements by letting virtual threads release their underlying platform threads in nearly all of those cases. So the honest advice is not “always use ReentrantLock.” The honest advice is “know your JDK, test the blocking path, and verify with JFR.”
What we have learned
The big lesson is simple. Virtual threads are powerful, but the carrier is still a finite resource. On Java 21–23, blocking while holding a monitor can pin that carrier, and enough pinning can erase the benefits of virtual threads. ReentrantLock usually gives you a safer waiting path in that older runtime era because the parking path is handled differently. On Java 24+, the synchronized story improves a lot thanks to JEP 491, so the recommendation becomes less absolute and more version-aware. If you want certainty, do not guess from code style alone — measure with JFR and check where the actual blocking happens

