Java 24 - Thread pinning revisited

#java Apr 9, 2025 5 min Mike Kowalski

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 the java.util.concurrent.locks package based solely upon which best solves the problem at hand.

JEP 491 (Synchronize Virtual Threads without Pinning)

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?

Software engineer believing in craftsmanship and the power of fresh espresso. Writing in & about Java, distributed systems, and beyond. Mikes his own opinions and bytes.