Mastering Virtual Threads: Avoiding Pinning in Synchronized Blocks
Virtual threads have revolutionized concurrent programming in Java, enabling developers to build highly scalable applications for I/O-intensive workloads without the complexity of asynchronous code. Unlike platform threads, virtual threads are lightweight and managed by the JVM, allowing millions of them to coexist. However, certain scenarios can cause a virtual thread to pin its carrier platform thread, undermining scalability. In this article, we'll explore what pinning is, examine a common cause—synchronized blocks—and learn how to detect and fix it.
Understanding Virtual Thread Pinning
What Is Pinning?
Virtual threads are mounted onto carrier threads (platform threads) by the JVM scheduler. Ideally, a virtual thread should yield the carrier thread when it blocks (e.g., during I/O), allowing other virtual threads to use it. Pinning occurs when a virtual thread cannot unmount from its carrier, causing both to remain blocked. This reduces concurrency and defeats the purpose of virtual threads.

Common Causes of Pinning
Pinning can happen in several situations:
- CPU-intensive operations: Virtual threads are designed for I/O; heavy computation should be avoided.
- Native method calls: Blocking inside native code prevents unmounting.
- Synchronized blocks or methods: When a virtual thread holds a monitor (via
synchronized), it cannot be unmounted because the monitor is tied to the carrier thread.
Of these, synchronized blocks are the most common source of unintended pinning in typical applications.
Pinning with Synchronized Blocks: A Practical Example
The CartService Example
Imagine an e-commerce service that updates a shopping cart. To protect shared state, we might use a synchronized block on a per-product lock. Here's a simplified implementation:
We create a CartService class with a ConcurrentHashMap storing product quantities and a separate map for locks. The update method acquires a lock for the specific product, simulates an API call, then updates the map. Using synchronized(lock) ensures thread safety but introduces pinning when a virtual thread runs the block.
Simulating a Slow API
To mimic an I/O operation, we use Thread.sleep(50) inside the synchronized block. In reality, this would be a call to an external service. Since Thread.sleep() does not release the monitor, the virtual thread stays pinned to its carrier for the entire 50 milliseconds—substantially limiting throughput under load.
The code structure looks like this:
synchronized (lock) {
simulateAPI(); // Thread.sleep(50)
products.merge(productId, quantity, Integer::sum);
}
Detecting Pinning with Java Flight Recorder
Setting Up JFR
Java Flight Recorder (JFR) is a built-in tool for monitoring and diagnosing JVM behavior. We can enable an event called jdk.VirtualThreadPinned to detect when a virtual thread is pinned. In a test, we start a recording, run our service inside virtual threads, and then examine the events.
Interpreting Results
After running the test, we look for VirtualThreadPinned events. Each event indicates where pinning occurred. In our example, JFR will show that the synchronized block on lock caused pinning. This confirms that the synchronized keyword is the culprit.

recording.enable("jdk.VirtualThreadPinned");
// ... run test ...
List<RecordedEvent> events = recording.dump();
events.forEach(e -> System.out.println(e.getStackTrace()));
Fixes and Future Improvements
Replacing synchronized with ReentrantLock
The immediate solution is to replace synchronized blocks with ReentrantLock from java.util.concurrent.locks. Unlike monitors, ReentrantLock is not tied to the carrier thread, allowing the virtual thread to unmount while waiting for the lock or during blocking operations inside the critical section. The refactored code:
private final Map<String, Lock> locks = new ConcurrentHashMap<>();
public void update(String productId, int quantity) {
Lock lock = locks.computeIfAbsent(productId, k -> new ReentrantLock());
lock.lock();
try {
simulateAPI();
products.merge(productId, quantity, Integer::sum);
} finally {
lock.unlock();
}
LOGGER.info("Updated Cart for {} {}", productId, quantity);
}
This change eliminates pinning because ReentrantLock does not hold a monitor on the carrier thread. The virtual thread can now yield the carrier while sleeping, dramatically improving scalability.
JDK 24 Enhancements
The Java team is actively addressing pinning. As of JDK 24, some progress has been made to reduce the impact of synchronized blocks. While not a full fix, improvements in the JVM scheduler allow virtual threads to more gracefully handle short synchronized sections. However, for long-running operations inside synchronized blocks, the recommendation remains to use ReentrantLock or avoid synchronization altogether by using data structures like ConcurrentHashMap with atomic methods.
Conclusion
Virtual threads are a powerful tool, but developers must be aware of pinning pitfalls. Synchronized blocks are a common source; using JFR to detect pinning is straightforward. By replacing synchronized with ReentrantLock or leveraging JDK 24's enhancements, you can keep your applications free from pinning and fully benefit from virtual thread scalability. Remember: for I/O-bound work, let the virtual thread yield—never pin it down.
Related Articles
- How to Set Up Grafana Assistant for Instant Infrastructure Insights
- Coursera-Udemy Merger Finalized: No Immediate Changes, AI-Driven Expansion Coming
- Kazakhstan Renews Coursera Pact, Mandates AI Literacy for All University Students
- Nature's Tiny Terminators: 10 Fascinating Facts About Scorpions' Metal-Reinforced Weapons
- Mastering Data Normalization: A Step-by-Step Guide to Bulletproof ML Performance
- Industrial AI Revolution: NVIDIA and Partners Deploy Production-Ready AI at Hannover Messe 2026
- Java Maps Unraveled: Essential Q&A for Developers
- Black Educator Reveals the Hidden Cost of Fighting for Radical Change in Schools