Concurrency

62. Virtual Threads — Deep Dive: Pinning and Observability

Virtual threads are pinned to their carrier OS thread when blocking inside synchronized — preventing other virtual threads from running on that carrier; use ReentrantLock instead and observe pinning via JFR.

Java 21+ (standard)

Explanation

A virtual thread is normally mounted on a carrier OS thread, runs, then unmounts (parks) when it blocks on I/O, Thread.sleep, or a lock. Another virtual thread mounts the freed carrier. This is how millions of virtual threads share a small OS-thread pool.

Pinning breaks this: when a virtual thread blocks inside a synchronized block or method, it cannot unmount — it pins the carrier for the duration of the block. If all carriers are pinned, new virtual threads cannot run and throughput collapses. This is the primary performance pitfall of virtual threads.

Fix: replace synchronized with java.util.concurrent.locks.ReentrantLock in code used by virtual threads. Native methods and certain Object.wait() uses also cause pinning. Detection: run with -Djdk.tracePinnedThreads=full (prints stack traces on pin events), or use JFR event jdk.VirtualThreadPinned with jfr print or JMC.

Example

import java.util.concurrent.Executors;
import java.util.concurrent.locks.ReentrantLock;

public class VirtualThreadPinning {
    // BAD: synchronized pins the carrier thread
    static class PinnedCounter {
        private int count = 0;
        public synchronized void increment() throws InterruptedException {
            Thread.sleep(10); // blocks while pinned!
            count++;
        }
        public synchronized int get() { return count; }
    }

    // GOOD: ReentrantLock allows unmounting
    static class UnpinnedCounter {
        private int count = 0;
        private final ReentrantLock lock = new ReentrantLock();
        public void increment() throws InterruptedException {
            lock.lock();
            try {
                Thread.sleep(10); // virtual thread unmounts here — carrier is free!
                count++;
            } finally { lock.unlock(); }
        }
        public int get() { lock.lock(); try { return count; } finally { lock.unlock(); } }
    }

    public static void main(String[] args) throws InterruptedException {
        int tasks = 20;

        // GOOD version: high throughput with virtual threads
        UnpinnedCounter goodCounter = new UnpinnedCounter();
        long goodStart = System.currentTimeMillis();
        try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < tasks; i++) {
                exec.submit(() -> {
                    try { goodCounter.increment(); }
                    catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                });
            }
        }
        long goodTime = System.currentTimeMillis() - goodStart;
        System.out.printf("UnpinnedCounter result=%d, time=%dms (all concurrent)%n",
            goodCounter.get(), goodTime);

        // Enable pinning trace at JVM startup:
        // java -Djdk.tracePinnedThreads=full VirtualThreadPinning
        System.out.println("Run with -Djdk.tracePinnedThreads=full to see pinning events");
        System.out.println("isVirtual: " + Thread.currentThread().isVirtual()); // false (main)
    }
}

Sources