Java 24: Virtual Threads in synchronized Without Pinning

Introduction Virtual threadsClick to read: Virtual threads, final in Java 21, let an application run huge numbers of blocking tasks on a few operating system threads, by unmounting a virtual thread from its carrier thread whenever it blocks. But Java 21 had one important exception: a virtual thread which blocked while inside a synchronized block or method could not be unmounted - it stayed pinned to its carrier, which was then blocked too. Since synchronized is everywhere in Java code and libraries, pinning was the most common reason applications did not scale as expected with virtual threads, and in the worst case it caused deadlocks. Java 24Click to read tutorial: Overview of New Features in Java 24 (JEP 491) removed this limitation. This tutorial explains what pinning was, shows the difference between Java 23 and Java 24 with a runnable example, and explains what it means for your code. Why synchronized pinned virtual threads Java's monitors - the locks behind synchronized - were implemented in the JVM by recording which platform thread owned each monitor. A virtual thread holding a monitor therefore could not leave its carrier, because the JVM would lose track of the owner. So when it blocked inside synchronized - on I/O, on Thread.sleep(), or waiting for a monitor someone else held - the carrier thread blocked with it. The JVM has only as many carriers as CPU cores by default, so a few pinned threads could stall all the others. The common workaround was to replace synchronized with java.util.concurrent.locks.ReentrantLock, which virtual threads could always release their carrier for. Java 24 reimplemented monitors so that ownership is tracked per virtual thread, which allows a virtual thread to unmount while holding a monitor, while waiting to acquire one, and in Object.wait().
Seeing the difference The program below runs 10 virtual threads which each sleep for 100 ms - first inside a synchronized block, then while holding a ReentrantLock. To make the effect easy to see, the virtual thread scheduler is limited to one carrier thread with the system properties jdk.virtualThreadScheduler.parallelism and maxPoolSize. Each thread has its own lock, so they never wait for each other -
Blocking inside synchronized vs ReentrantLock
//PinningDemo.java
import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.ReentrantLock;

public class PinningDemo {
  static long run(boolean useSynchronized) {
    long start = System.nanoTime();
    try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
      for (int i = 0; i < 10; i++) {
        Object monitor = new Object();
        ReentrantLock lock = new ReentrantLock();
        executor.submit(() -> {
          if (useSynchronized) {
            synchronized (monitor) {
              Thread.sleep(Duration.ofMillis(100));      // blocks while holding a monitor
            }
          } else {
            lock.lock();
            try {
              Thread.sleep(Duration.ofMillis(100));      // blocks while holding a ReentrantLock
            } finally {
              lock.unlock();
            }
          }
          return null;
        });
      }
    }
    return (System.nanoTime() - start) / 1_000_000;
  }

  public static void main(String[] args) {
    long sync = run(true);
    long lock = run(false);
    System.out.println("Java " + Runtime.version().feature() + ": 10 tasks on 1 carrier thread");
    System.out.println("  synchronized  : about " + Math.round(sync / 100.0) * 100 + " ms");
    System.out.println("  ReentrantLock : about " + Math.round(lock / 100.0) * 100 + " ms");
  }
}
The code is compiled for Java 21. Running it on a Java 23 JDK, where pinning still applies -
java -Djdk.virtualThreadScheduler.parallelism=1 -Djdk.virtualThreadScheduler.maxPoolSize=1 PinningDemo
 OUTPUT on Java 23
Java 23: 10 tasks on 1 carrier thread
  synchronized  : about 1100 ms
  ReentrantLock : about 100 ms
And the same class files on Java 24 -  OUTPUT on Java 24
Java 24: 10 tasks on 1 carrier thread
  synchronized  : about 100 ms
  ReentrantLock : about 100 ms
Explanation of the output
  • On Java 23, each virtual thread sleeping inside synchronized pinned the only carrier thread, so the 10 sleeps ran one after another - about 1 second. With ReentrantLock, each sleeping thread unmounted and freed the carrier, so all 10 slept at the same time - about 100 ms.
  • On Java 24, synchronized behaves like ReentrantLock: the virtual threads unmount while holding their monitors, and all 10 sleeps overlap.
  • No code changed between the two runs - the same class files run faster just by upgrading the JDK.
What this means for your code
  • No need to replace synchronized: code and libraries using synchronized - which is to say most of them - now work well with virtual threads on Java 24 and later. Choose between synchronized and ReentrantLock on their merits again, not because of virtual threads.
  • Remaining pinning cases: a virtual thread is still pinned while it runs native code (through JNI or the FFM APIClick to read tutorial: Java 22 Foreign Function & Memory API Tutorial) and while running class initializers - rare in practice.
  • Diagnostics: the jdk.tracePinnedThreads system property of Java 21 was removed; the JFR event jdk.VirtualThreadPinned reports any remaining pinning, with the reason.
  • Upgrade advice: if you adopted virtual threads on Java 21 and saw scalability problems or rewrote code to avoid synchronized, Java 24 or the Java 25 LTSClick to read tutorial: Overview of New Features in Java 25 is a strong reason to upgrade.
Summary In this tutorial we saw how, before Java 24, a virtual thread which blocked inside synchronized stayed pinned to its carrier thread, because monitors were owned by platform threads. We ran the same program on Java 23 and Java 24 with a single carrier thread, and saw ten 100 ms sleeps inside synchronized take about a second on Java 23 but overlap on Java 24, just like with ReentrantLock. Lastly, we went through what the change means for existing code, and the remaining cases of pinning. Next, read about quantum-resistant cryptography in Java 24Click to read tutorial: Java 24 Quantum-Resistant Crypto: ML-KEM and ML-DSA.