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
Seeing the difference
The program below runs 10 virtual threads which each sleep for 100 ms - first inside a
The code is compiled for Java 21. Running it on a Java 23 JDK, where pinning still applies -
OUTPUT on Java 23
And the same class files on Java 24 -
OUTPUT on Java 24
Explanation of the output
What this means for your code
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().
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");
}
}
java -Djdk.virtualThreadScheduler.parallelism=1 -Djdk.virtualThreadScheduler.maxPoolSize=1 PinningDemo
Java 23: 10 tasks on 1 carrier thread synchronized : about 1100 ms ReentrantLock : about 100 ms
Java 24: 10 tasks on 1 carrier thread synchronized : about 100 ms ReentrantLock : about 100 ms
- On Java 23, each virtual thread sleeping inside
synchronizedpinned the only carrier thread, so the 10 sleeps ran one after another - about 1 second. WithReentrantLock, each sleeping thread unmounted and freed the carrier, so all 10 slept at the same time - about 100 ms. - On Java 24,
synchronizedbehaves likeReentrantLock: 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.
- 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 betweensynchronizedandReentrantLockon 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.tracePinnedThreadssystem property of Java 21 was removed; the JFR eventjdk.VirtualThreadPinnedreports 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.
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.Continue Reading Java 22 to 25 Tutorials
Overview of New Features in Java 24Click to read tutorial: Overview of New Features in Java 24 Java 24 Stream Gatherers Tutorial with ExamplesClick to read tutorial: Java 24 Stream Gatherers Tutorial with Examples Java 24 Class-File API Tutorial with ExamplesClick to read tutorial: Java 24 Class-File API Tutorial with Examples AOT Cache in Java 24 and 25: Faster StartupClick to read tutorial: AOT Cache in Java 24 and 25: Faster Startup Java 24 Quantum-Resistant Crypto: ML-KEM and ML-DSAClick to read tutorial: Java 24 Quantum-Resistant Crypto: ML-KEM and ML-DSA Overview of New Features in Java 22Click to read tutorial: Overview of New Features in Java 22 Overview of New Features in Java 23Click to read tutorial: Overview of New Features in Java 23 Overview of New Features in Java 25Click to read tutorial: Overview of New Features in Java 25 Java 22 Foreign Function & Memory API TutorialClick to read tutorial: Java 22 Foreign Function & Memory API Tutorial
Overview of New Features in Java 24Click to read tutorial: Overview of New Features in Java 24 Java 24 Stream Gatherers Tutorial with ExamplesClick to read tutorial: Java 24 Stream Gatherers Tutorial with Examples Java 24 Class-File API Tutorial with ExamplesClick to read tutorial: Java 24 Class-File API Tutorial with Examples AOT Cache in Java 24 and 25: Faster StartupClick to read tutorial: AOT Cache in Java 24 and 25: Faster Startup Java 24 Quantum-Resistant Crypto: ML-KEM and ML-DSAClick to read tutorial: Java 24 Quantum-Resistant Crypto: ML-KEM and ML-DSA Overview of New Features in Java 22Click to read tutorial: Overview of New Features in Java 22 Overview of New Features in Java 23Click to read tutorial: Overview of New Features in Java 23 Overview of New Features in Java 25Click to read tutorial: Overview of New Features in Java 25 Java 22 Foreign Function & Memory API TutorialClick to read tutorial: Java 22 Foreign Function & Memory API Tutorial