Java 24 - Thread pinning revisited
Back in 2023, I got extremely excited about the release of Java 21. It felt like a significant breakthrough, similar to Java 8. I was also one of the early adopters of virtual threads in my production code. Back then, thread pinning was the main limitation of this promising solution.
The negative impact of thread pinning was initially exaggerated. Shortly after the Java 21 release, many popular frameworks and libraries were updated to become more virtual-threads-friendly. For many standard web apps, updating dependencies was enough to enjoy great scalability and simplicity that virtual threads bring. Yet, I still found it reasonable to keep an eye on thread pinning using Java Flight Recorder (JFR), as I suggested in one of my articles.
The recently released Java 24 changed everything when it comes to the thread pinning issue. Thanks to JEP 491, almost all the pinning scenarios described in the past are no longer valid. This change alone should be a strong reason to upgrade!
In this article, we will revisit the well-known thread pinning scenarios against Java 24. We will also check if actively
monitoring for pinned threads still makes sense for most of the apps. Finally, we will discuss the state of using
synchronized
keyword in our codebases.
Thread pinning detection
To confirm if thread pinning is still an issue, we need a tool that could detect and report it. However, the number of available options has just reduced.
Before Java 24, the easiest way to monitor thread pinning occurrences was to set the -Djdk.tracePinnedThreads=full
command line option. Despite its limitations,
it provided simple log-based reporting without any code required.
Yet, the jdk.tracePinnedThreads
command line option is no longer available starting with Java 24. As we will discover,
it’s simply no longer needed.
For our experimentation purposes, we will rely on Java Flight Recorder (JFR) jdk.VirtualThreadPinned
event instead.
This JFR event should be emitted every time the thread pinning occurs. Enabling JFR recording from within the app requires
a bit of configuration, that I’ve already described in the past.
We will skip that part for readability.
synchronized revisited
Java synchronized
keyword reputation got much worse with the release of Java 21. In short, blocking operations executed
from within the synchronized
block would pin the carrier thread. Running the following code with Java 21 leaves no doubts:
static final Object lock = new Object();
static void example1() throws InterruptedException {
var cdl = new CountDownLatch(1);
Thread.ofVirtual()
.name("vt-example")
.start(() -> {
synchronized (lock) {
someBlockingOperation(); // pinning with Java 21
}
cdl.countDown();
});
cdl.await();
}
There was a thread pinning detected!
jdk.VirtualThreadPinned {
startTime = 21:49:33.110 (2025-04-01)
duration = 102 ms
eventThread = "vt-example" (javaThreadId = 27, virtual)
stackTrace = [
java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 687
java.lang.VirtualThread.parkNanos(long) line: 646
java.lang.VirtualThread.sleepNanos(long) line: 803
java.lang.Thread.sleep(long) line: 507
App.someBlockingOperation() line: 51
...
]
}
When running the same example on Java 24, there is no jdk.VirtualThreadPinned
event being reported. Regardless of
how many times it’s run, the result remains the same.
The second example is much more subtle. When using ConcurrentHashMap
(directly or via popular caching libraries),
a blocking call made from within the virtual thread would cause pinning with Java 21. The reason was a hidden synchronized
used internally to lock a particular key. Luckily, with Java 24, that’s no longer an issue!
static void example2() throws InterruptedException {
Map<String, Integer> cache = new ConcurrentHashMap<>();
var cdl = new CountDownLatch(1);
Thread.ofVirtual().name("vt").start(() -> {
Integer cachedValue = cache.computeIfAbsent("foo", key -> {
// blocking call
var calculated = readFromDb(key); // pinning with Java 21
System.out.println("Caching value for '" + key + "': " + calculated);
return calculated;
});
System.out.println("Cached value: " + cachedValue);
cdl.countDown();
});
cdl.await();
}
Running our second example with Java 24 also causes no thread pinning. The authors of JEP 491 kept their promise!
Before Java 24, one way of fixing the first example was to replace synchronized
with the ReentrantLock
.
Most performance-critical code like this should already be fixed today. Yet, this technique wouldn’t work for
the second case of the ConcurrentHashMap
/cache. This is where Java 24 truly shines. In-memory data structures like this
are often used for performance reasons. Removing potential thread pinning makes such code much more predictable.
What’s changed?
If you don’t want to read the whole JEP 491 (Synchronize Virtual Threads without Pinning),
here is a TL;DR. The synchronized
keyword relies on monitors associated with objects. Until Java 24, when a virtual
thread acquired the monitor via synchronized
, the JVM perceived the carrier platform thread as the one holding it.
Since virtual threads could be moved between platform threads, pinning was necessary to ensure that only one thread/virtual
thread could acquire the monitor at a time.
Since Java 24, “virtual threads can acquire, hold, and release monitors, independently of their carriers”.
This means that synchronized
should no longer cause thread pinning. This is a significant win for the whole Java
community, as the synchronized
is still widely used in various codebases.
To monitor or not to monitor?
It’s worth noting that not all pinning scenarios are gone. Virtual threads calling native code by using native methods or the Foreign Function & Memory API can still be pinned. However, this shouldn’t be the case for most of the Java apps running on production. Let’s not even consider other, much less popular scenarios.
Since synchronized
was the most popular culprit for thread pinning, most apps should no longer need any
thread pinning monitoring. That’s exactly why the jdk.tracePinnedThreads
command line option has disappeared in
Java 24. Monitoring jdk.VirtualThreadPinned
JFR events shouldn’t do any harm, but it also feels a bit redundant.
However, if calling native code is something your app relies on, the JFR-based detection approach I suggested still remains relevant.
Forgive us, synchronized…
The release of Java 21 started a “crusade” against synchronized
. To avoid thread pinning, we began replacing it
with java.util.concurrent.locks
alternatives like the ReentrantLock
. Most of the popular libraries and frameworks have
been updated close to the Java 21 release, making pinning much less problematic than initially anticipated.
Yet, I don’t think we should keep removing synchronized
from our codebases in 2025 and beyond. As per JEP 491:
Once the
synchronized
keyword no longer pins virtual threads, you can choose between synchronized and the APIs in thejava.util.concurrent.locks
package based solely upon which best solves the problem at hand.
JEP authors no longer recommend replacing synchronized
with ReentrantLock
. Reverting changes like this
introduced in pre-Java 24 times may not make much sense today.
Summary
Java 24 has resolved the virtual thread pinning issue when using synchronized
. In practical terms, most projects
should be free from pinning. As a result, actively monitoring for pinned threads should probably be limited to the apps
relying on remaining use cases like native calls. “Standard” apps should use the best tools for the job and let the JVM
handle the rest.
All it takes to enjoy these improvements is to bump the Java version in the project. Not many tools can offer so much with such a low effort, so… what’s your excuse?