How to Detect and Fix Virtual Thread Pinning in Java

By

Introduction

Virtual threads in Java 21+ revolutionize concurrent programming by allowing developers to create millions of lightweight threads without worrying about operating system limits. However, a common pitfall known as pinning can occur when a virtual thread holds a lock that prevents the carrier platform thread from being reused, reducing scalability. This guide walks you through identifying pinning scenarios, debugging them with Java Flight Recorder (JFR), and applying fixes—including using ReentrantLock and taking advantage of JDK 24 enhancements.

How to Detect and Fix Virtual Thread Pinning in Java
Source: www.baeldung.com

What You Need

Step-by-Step Guide

Step 1: Understand Virtual Thread Pinning Scenarios

Virtual threads are mounted on platform (carrier) threads for execution. Pinning occurs when a virtual thread cannot be unmounted from its carrier thread, typically because of:

While heavy CPU work should be avoided in virtual threads altogether, locking issues are fixable. This guide focuses on the synchronized scenario.

Step 2: Set Up Sample Code with a Synchronized Block

Create a class CartService that simulates updating a shopping cart. The update method uses a synchronized block on a per-product lock:

public class CartService {
    private final Map<String, Integer> products = new ConcurrentHashMap<>();
    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 downstream API call
            products.merge(productId, quantity, Integer::sum);
        }
        System.out.println("Updated Cart for " + productId + " " + quantity);
    }

    private void simulateAPI() {
        try {
            Thread.sleep(50);  // Simulates blocking I/O
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

This code holds the synchronized lock while sleeping, which can cause pinning.

Step 3: Enable JFR and Run a Virtual Thread Task

Use JFR to capture events when a virtual thread is pinned. Create a test that starts a recording, executes the update method inside a virtual thread, and stops the recording:

import jdk.jfr.Recording;
import java.nio.file.Path;

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

        Thread.ofVirtual().start(() -> {
            CartService cart = new CartService();
            cart.update("product-1", 2);
        }).join();

        recording.stop();
        recording.dump(file);
    }
}

Run the test. The recording will be saved to pinning.jfr.

Step 4: Analyze JFR Events for Pinning

Open the pinning.jfr file in JDK Mission Control (or parse it programmatically). Look for VirtualThreadPinned events. They indicate the virtual thread was pinned on a platform thread during the synchronized block. Count and examine stack traces to confirm the pinned location (e.g., CartService.update).

How to Detect and Fix Virtual Thread Pinning in Java
Source: www.baeldung.com

If no events appear, pinning is absent; otherwise, proceed to fix.

Step 5: Replace Synchronized with ReentrantLock

The most straightforward fix is to replace synchronized with ReentrantLock, which does not cause pinning because the lock is managed at the Java level, not the JVM intrinsic lock level. Modify CartService:

import java.util.concurrent.locks.ReentrantLock;

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

    public void update(String productId, int quantity) {
        ReentrantLock lock = locks.computeIfAbsent(productId, k -> new ReentrantLock());
        lock.lock();
        try {
            simulateAPI();
            products.merge(productId, quantity, Integer::sum);
        } finally {
            lock.unlock();
        }
        System.out.println("Updated Cart for " + productId + " " + quantity);
    }
    // simulateAPI remains the same
}

Now the lock is explicitly acquired and released; virtual threads can yield while waiting for the lock, avoiding pinning.

Step 6: Re-run and Verify No Pinning

Run the same JFR test again with the modified code. After execution, open the new recording. You should see zero VirtualThreadPinned events. This confirms the fix works.

Step 7: Consider JDK 24 Improvements

JDK 24 introduces further reductions in pinning scenarios. For example, synchronized blocks will no longer cause pinning when the lock is uncontested (i.e., no contention) – the JVM can unmount the virtual thread before entering the block. However, for robust code, it's still best to avoid synchronized in virtual-thread-heavy code. Also note that native method calls and CPU-heavy work remain pinning-prone.

Tips for Avoiding Virtual Thread Pinning

Tags:

Related Articles

Recommended

Discover More

Microsoft’s Agent 365 Reaches GA: The Battle Against Shadow AI IntensifiesApple's iPhone 17 Defies Market Downturn: US Smartphone Sales Shrink but Apple Gains Share in Q1 2026New Open-Source Framework Plasmo Dramatically Simplifies Chrome Extension DevelopmentThe Shadow AI Security Crisis: How Vibe-Coded Apps Are Leaking Corporate DataHow to Leverage Congressional Hearings to Safeguard NIH Funding and Vaccine Research