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.

Mastering Virtual Threads: Avoiding Pinning in Synchronized Blocks
Source: www.baeldung.com

Common Causes of Pinning

Pinning can happen in several situations:

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.

Mastering Virtual Threads: Avoiding Pinning in Synchronized Blocks
Source: www.baeldung.com
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.

Tags:

Recommended

Discover More

Canonical Kicks Off Overhaul of Launchpad Series Page for Ubuntu 26.04 LTSHow to Appreciate the Motorola Nexus 6’s Groundbreaking Design and LegacyFedora Linux 44 Global Virtual Release Party: Everything You Need to KnowRebuilding Search for High Availability in GitHub Enterprise Server: A Step-by-Step GuideMastering List Flattening in Python: From Nested to One-Dimensional