215111 Stack

2026-05-14 05:19:49

Preventing Virtual Thread Pinning in Synchronized Code: A Practical Guide

Learn how virtual thread pinning occurs in synchronized blocks, detect it with JFR, and fix it using ReentrantLock or reordering—with a look at JDK 24 improvements.

Introduction

Virtual threads revolutionize Java concurrency by allowing developers to scale I/O-intensive applications without the overhead of managing operating system threads. Unlike platform threads, which require careful resource management and non-blocking I/O patterns (often leading to complex asynchronous code like CompletableFuture), virtual threads are lightweight and can be created in massive numbers. However, despite eliminating thread scarcity, certain situations can still cause virtual threads to block the underlying carrier thread—a phenomenon known as pinning. In this guide, we’ll explore common pinning scenarios, demonstrate how to detect them using Java Flight Recorder, and outline solutions—some of which will be fully addressed in JDK 24.

Preventing Virtual Thread Pinning in Synchronized Code: A Practical Guide
Source: www.baeldung.com

Understanding Virtual Thread Pinning

Virtual threads are mounted onto platform (carrier) threads by the JVM scheduler. Ideally, a virtual thread executes its task and then unmounts, freeing the carrier thread for other work. However, under certain conditions, both the virtual thread and its carrier become blocked for an extended period. While this doesn’t break business logic, it degrades application scalability. Common causes include:

  • Heavy CPU-bound operations (which should be avoided on virtual threads)
  • Waiting while holding a lock (e.g., inside a synchronized block)
  • Blocked within native method execution

In this article, we’ll focus on the second scenario—pinning due to synchronized blocks—and show how to fix it.

2. A Common Pinning Scenario: synchronized Block

Consider a shopping cart service where updates to a product’s quantity must be atomic. Using a synchronized block on a per-product lock is a straightforward approach:

public class CartService {
    private final Map<String, Integer> products = new HashMap<>();
    private final Map<String, Object> locks = new ConcurrentHashMap<>();

    public void update(String productId, int quantity) {
        Object lock = locks.computeIfAbsent(productId, k -> new Object());
        synchronized (lock) {
            simulateAPI();  // Simulates a slow external call
            products.merge(productId, quantity, Integer::sum);
        }
        LOGGER.info("Updated Cart for {} {}", productId, quantity);
    }

    private void simulateAPI() {
        try {
            Thread.sleep(50);  // Simulated I/O delay
        } catch (InterruptedException ex) {
            throw new RuntimeException(ex);
        }
    }
}

Here, the simulateAPI() method mimics a downstream call. Because the thread sleeps inside a synchronized block, the virtual thread cannot unmount from the carrier thread—it stays pinned. The JVM scheduler cannot release the carrier thread for other virtual threads during this time, reducing scalability.

2.1 Detecting Pinning with Java Flight Recorder

To verify pinning, we can use the Java Flight Recorder (JFR) to monitor virtual thread events. Here’s a test that records pinning events while running the CartService.update method inside a virtual thread:

@Test
void givenJFRIsEnabled_whenVThreadIsBlocked_thenDetectVThreadPinned() throws Exception {
    Path file = Path.of("pinning.jfr");
    try (Recording recording = new Recording()) {
        recording.enable("jdk.VirtualThreadPinned");
        recording.start();

        // Execute update inside a virtual thread
        Thread.startVirtualThread(() -> {
            CartService cart = new CartService();
            cart.update("product-1", 2);
        }).join();

        recording.stop();
        recording.dump(file);
    }
    // Verify the recorded file contains pinning events
    // (parsing logic omitted for brevity)
}

When you run this test, JFR will capture jdk.VirtualThreadPinned events, confirming that the virtual thread was pinned while holding the synchronized lock.

3. How to Fix Pinning in synchronized Blocks

The fundamental cause is that the JVM cannot unmount a virtual thread while it is inside a synchronized block. To avoid pinning, we have two primary strategies:

Preventing Virtual Thread Pinning in Synchronized Code: A Practical Guide
Source: www.baeldung.com
  1. Move blocking operations outside the synchronized block. In the example, move simulateAPI() before acquiring the lock or after releasing it, if the business logic allows.
  2. Replace synchronized with a ReentrantLock. The JVM treats ReentrantLock differently—it can unmount the virtual thread while waiting for the lock, so no pinning occurs. However, ensure that any blocking I/O is also moved outside the lock.

For the cart service, a clean fix is:

public void update(String productId, int quantity) {
    // Perform the blocking call first (without holding any lock)
    simulateAPI();
    // Then synchronize only the critical update
    Object lock = locks.computeIfAbsent(productId, k -> new Object());
    synchronized (lock) {
        products.merge(productId, quantity, Integer::sum);
    }
    LOGGER.info("Updated Cart for {} {}", productId, quantity);
}

If you must keep the blocking call inside the lock, switch to ReentrantLock:

private final Lock lock = new ReentrantLock();

public void update(String productId, int quantity) {
    lock.lock();
    try {
        simulateAPI();
        products.merge(productId, quantity, Integer::sum);
    } finally {
        lock.unlock();
    }
}

With ReentrantLock, the virtual thread can be unmounted while waiting to acquire the lock, avoiding pinning.

4. What’s Coming in JDK 24

The Java team is aware of pinning issues and has been working on improvements. In JDK 24, certain scenarios that previously caused pinning within synchronized blocks will be automatically resolved. Specifically, the JVM will be able to unmount virtual threads even when they are inside a synchronized method or block, as long as no native method or JNI call is involved. This means that for pure Java code, you may not need to refactor your synchronized usage to avoid pinning—the JVM will handle it transparently. However, until JDK 24 is widely available, following the best practices described above ensures your application remains scalable today.

Conclusion

Virtual threads are a powerful tool for concurrency, but pinning can undermine their benefits. By understanding which operations cause pinning—especially synchronized blocks combined with blocking I/O—you can design your code to avoid it. Move blocking calls outside locks, or use ReentrantLock instead of synchronized where appropriate. Monitoring with JFR helps confirm that your virtual threads remain unmounted. As the JVM evolves, JDK 24 will further reduce the need for such workarounds. Apply these practices today to build truly scalable virtual-thread-based applications.