Java Coding Notes
Notes on Java, the JVM and writing software that lasts

Virtual threads: the pinning problem and how to find it

ยท by Serguey Asael Shinder / Serguey Shinder

Virtual threads make blocking cheap. They do not make blocking free, and there is one case where they make it expensive: pinning.

What pinning is

A virtual thread normally unmounts from its carrier platform thread when it blocks. While it is unmounted, the carrier is free to run something else. That is the whole trick.

Inside a synchronized block the virtual thread cannot unmount. It holds the carrier thread for the duration of the block, including any blocking call inside it. Enough of those at once and you have exactly the thread-starvation problem virtual threads were supposed to remove.

private final Object lock = new Object();

void fetch(String url) {
    synchronized (lock) {        // carrier is pinned for the whole call
        httpClient.send(request(url), ofString());
    }
}

Finding it

Run with the diagnostic on and the JVM will tell you:

-Djdk.tracePinnedThreads=full

You get a stack trace at every pinning event. short gives you just the frame that did it. This is a development flag โ€” it is noisy and it costs; do not leave it on in production.

Fixing it

Replace the monitor with a ReentrantLock. It parks the virtual thread properly rather than pinning the carrier.

private final ReentrantLock lock = new ReentrantLock();

void fetch(String url) {
    lock.lock();
    try {
        httpClient.send(request(url), ofString());
    } finally {
        lock.unlock();
    }
}

The part people miss

Your own code is usually not the problem. The pinning is almost always in a library โ€” an old connection pool, a logging appender, a driver written in 2011 with a synchronized around the socket write. That is why the flag matters more than the rule: you cannot grep for this in code you did not write.

And the honest caveat

Work is ongoing in the JDK to let virtual threads unmount inside synchronized. If you are on a recent release, check whether this still applies to you before rewriting locks across a codebase. The diagnostic flag answers that question in one run, which is a better use of an afternoon than a refactor performed on faith.

Written against Java 21. Verify against your JDK before acting on it.