"Replace synchronized with ReentrantLock, or your virtual threads will pin."
That was correct in Java 21. It stopped being correct in Java 24. It is also the single most quoted piece of virtual-threads advice on the internet, which means a lot of codebases are still carrying a workaround for a problem that no longer exists.
## Why it was ever true
The JVM tracked monitor ownership at the platform thread level, not the virtual thread level. Enter a synchronized method and the JVM recorded your *carrier* as holding the monitor.
If that virtual thread then unmounted mid-method, the scheduler would mount a different virtual thread on the same carrier — and the JVM would consider that thread to hold the monitor. Mutual exclusion, gone.
Rather than allow that, the JVM pinned. A blocking read inside a synchronized method blocked the carrier, and the OS thread underneath it.
At scale that is not a slowdown. It is a deadlock waiting to happen: every carrier pinned, nothing left to schedule, and a thread dump that looks like the application is idle.
So library maintainers rewrote working code to use ReentrantLock. Not because it was better, but to work around a JVM implementation detail.
## What JEP 491 changed
Java 24 moved ownership tracking to the virtual thread itself. A virtual thread can now unmount inside a synchronized block, block on monitor acquisition, and call Object.wait() — all without holding on to its carrier.
The JEP's guidance is unambiguous: use synchronized where practical, use java.util.concurrent.locks when you need the flexibility. And do not revert code you have already migrated. ReentrantLock is not wrong; it is just no longer mandatory.
## Two things survived
Pinning still happens through native frames. If a virtual thread calls native code — a native method, or the FFM API — and that code calls back into Java and blocks, the thread is pinned. Class initialisers behave the same way. This is a smaller surface, but it is not zero, and it is harder to spot because it usually sits inside a dependency.
jdk.tracePinnedThreads is gone. Removed, not deprecated. Setting it does nothing at all — no warning, no output. The jdk.VirtualThreadPinned JFR event replaces it, extended to carry the pin reason and the carrier's identity.
# Does nothing on 24+. Silently.
-Djdk.tracePinnedThreads=full
If you are still grepping logs for pinning stack traces, you are grepping for output that is no longer produced. That is worse than having no diagnostics, because an empty result reads like a clean bill of health.
## The habit
Every piece of performance advice has a version range attached, and almost nobody writes it down. When advice tells you to work around the runtime, check whether the runtime still needs working around — and check what happened to the flag you were using to verify it.
Which of the two is your codebase still carrying: the ReentrantLock rewrites, or the tracePinnedThreads flag?