All articles

// Knowledge.log — 技術記事

Did JDK 24 End Virtual Thread Pinning? What Still Holds the Carrier Thread

JEP 491 freed synchronized from pinning; native calls still pin the carrier. JDK 21 vs 25 measured, JFR checks, and when the ReentrantLock rule still holds.

Back in the JDK 21 days, a lot of internal Java guides picked up a line like this: "in code that runs on virtual threads, replace synchronized with ReentrantLock because of pinning." It was reasonable advice. It's also the kind of line that goes into a guide and never gets reread, mostly because nobody opens the guide again either.

Now that JDK 25 LTS is reaching real codebases, teams are deleting that line wholesale, with a short justification: JEP 491, delivered in JDK 24, fixed pinning, the rule is dead, and synchronized is safe everywhere. That's the claim this post tests. Half of it is right. The trouble is "everywhere", which the JEP itself never signs off on. Whether that line stays in your guide depends on three things: what stopped pinning, what still pins a carrier thread, and how to see it in a JFR recording.

Where the headline came from

This claim didn't start as hallway talk. The clearest receipt is from Marvin Richter, in Java 24 Fixes the Last Virtual-Threads Problem: synchronized Without Pinning (June 18, 2026). He writes that synchronized code that used to pin "no longer pins under Java 24. No code needs to change", calls the objection "technically obsolete", and promises "no manual ReentrantLock migrations".

Before that, Dan Vega had already summed up the change in a post from April 9, 2025 as being able to "use synchronized methods and blocks without pinning", and Inside Java Newscast #80 (November 2024) went out titled "Java 24 Stops Pinning Virtual Threads (Almost)". Somewhere in the retelling, "(Almost)" fell off. If you run a service, that's the word you care about.

What JEP 491 changed, and what it says still pins

The old rule has a known source. JEP 444, which delivered virtual threads in JDK 21, recommended that you "avoid frequent and long-lived pinning by revising synchronized blocks or methods that run frequently and guard potentially long I/O operations to use java.util.concurrent.locks.ReentrantLock instead."

JEP 491 changes how the JVM implements synchronized:

> We will change the JVM's implementation of the synchronized keyword so that virtual threads can acquire, hold, and release monitors, independently of their carriers.

It's just as direct about the migration teams went through:

> We previously recommended solving frequent and long-lived pinning problems by migrating code from using synchronized to using ReentrantLock. Once the synchronized keyword no longer pins virtual threads, such migration will no longer be necessary. You need not revert code that has been migrated to use ReentrantLock back to using synchronized.

So far the claim holds up. But the JEP's summary says the change "will eliminate nearly all cases of virtual threads being pinned to platform threads". "Nearly" is doing all the work there, and the same document lists what's left:

> if a virtual thread calls native code, either through a native method or the Foreign Function & Memory API, and that native code calls back to Java code that performs a blocking operation or blocks on a monitor, then the virtual thread will be pinned

The "Future Work" section adds class loading and class initialization. For example: "When waiting for a class to be initialized by another thread (JVMS §5.5). This is a special case where the virtual thread blocks in the JVM, thus pinning the carrier."

The JDK 25 virtual threads documentation is even blunter: "A virtual thread is pinned when it runs a native method or a foreign function", and "Pinning does not make an application incorrect, but it might hinder its scalability." The FFM API has been final since JDK 22 (JEP 454), so that native path isn't exotic anymore. From here on, the baseline is JDK 25, the current LTS, which went GA on September 16, 2025.

The measurement

All numbers below were measured on 2026-09-26 on Ubuntu 26.04 LTS with 4 vCPUs (AMD EPYC-Rome Processor) and 7.6 GiB of RAM, without Docker. We ran Temurin 21.0.12+8 LTS and Temurin 25.0.4+7 LTS. The whole matrix took 44 s.

The design is deliberately small: 8 virtual threads, each blocking for 250 ms, with the scheduler limited to one carrier (-Djdk.virtualThreadScheduler.parallelism=1). With a single carrier, any pinning shows up as a queue. Instead of all 8 tasks finishing together in ~250 ms, they run one after another and take ~2 s. Each run writes a JFR recording with jdk.VirtualThreadPinned enabled and a 10 ms threshold. This setup is built to tell behaviors apart. It won't tell you anything about production capacity.

The scenarios that matter in PinningDemo.java:

case "sleep-sync-permon":
    synchronized (PER_TASK[idx]) { Thread.sleep(BLOCK_MS); }
    break;
case "sock-sync-shared":
    synchronized (SHARED) { socketRoundtrip(idx); }
    break;
case "ffm-nanosleep": {
    Class<?> c = Class.forName("FfmSleep");
    c.getMethod("sleep", long.class).invoke(null, BLOCK_MS);
    break;
}

permon uses one monitor per task, which separates pinning from lock contention. shared uses a single monitor. The sock scenarios make a localhost socket round-trip to a server running on platform threads that replies after 250 ms. ffm-nanosleep calls libc's nanosleep through FFM. The stub declares only the first parameter, req, even though the C prototype also takes rem:

Linker linker = Linker.nativeLinker();
SymbolLookup std = linker.defaultLookup();
NANOSLEEP = linker.downcallHandle(
        std.find("nanosleep").orElseThrow(() -> new RuntimeException("nanosleep symbol not found")),
        FunctionDescriptor.of(JAVA_INT, ADDRESS));

The harness is two Java files plus a short script that runs the matrix. PinningDemo.java holds the scenarios, the measurement and the recording, and FfmSleep.java holds the native downcall. ListJfrEvents.java shows up in the compile step, but it only lists the events registered in the JVM and plays no part in the measurements. To reproduce, compile like this. On JDK 21, FfmSleep only compiles with --release 21 --enable-preview, because FFM was still a preview API there:

# JDK 25
$JDK25/bin/javac -d build25 src/ListJfrEvents.java src/PinningDemo.java src/FfmSleep.java
# JDK 21
$JDK21/bin/javac -d build21 src/ListJfrEvents.java src/PinningDemo.java
$JDK21/bin/javac --release 21 --enable-preview -d build21-preview src/FfmSleep.java

Every run looks like this (the last argument is the directory for the .jfr file):

$JDK25/bin/java -Djdk.virtualThreadScheduler.parallelism=1 -Ddemo.jdkTag=25 -cp build25 PinningDemo <scenario> jfr

On JDK 25, the nanosleep downcall prints four restricted-method WARNING lines to stderr, starting with "A restricted method in java.lang.foreign.Linker has been called", unless you run with --enable-native-access=ALL-UNNAMED. They're warnings, not errors, and they don't affect the measurement.

Results (JDK 21 / JDK 25). With only 8 tasks, p95 and p99 are just the maximum, so total time is enough. "In flight" is the maximum number of tasks started at the same time, including tasks parked while they wait for a monitor:

ScenarioTotal time (21 / 25)In flight (21 / 25)Tasks/s (21 / 25)Pinned events (21 / 25)
sleep (control, no monitor)265.8 / 260.7 ms8 / 830.1 / 30.70 / 0
sleep-sync-permon2018.1 / 260.7 ms1 / 83.96 / 30.78 / 0
sleep-sync-shared2015.5 / 2013.8 ms1 / 83.97 / 3.978 / 0
sock-sync-permon2023.0 / 270.2 ms1 / 83.96 / 29.68 / 0
sock-sync-shared2022.4 / 2016.1 ms1 / 83.96 / 3.978 / 0
ffm-nanosleep (native, JDK 25 only)— / 2078.6 ms— / 8— / 3.85— / 1

No scenario had errors.

What the numbers say

Monitors don't pin anymore. On JDK 21, blocking inside a per-task monitor serialized all 8 tasks on the single carrier: ~2.0 s, one at a time, 8 pinned events. On JDK 25 the same code overlaps: ~0.26–0.27 s, 8 in flight, zero events, whether it's sleep or a socket. That's the real change in JEP 491, and it's a big one. Same design, 3.96 tasks/s on 21 and 30.7 on 25.

A shared monitor still serializes, and that isn't pinning. With a single lock, JDK 25 took 2013.8 ms and JDK 21 took 2015.5 ms. The "in flight" column shows the difference. On 25, all 8 tasks start right away because the carrier is free to mount them, and seven of them park waiting for the monitor. JEP 491 freed the carrier, but mutual exclusion is still there. Swapping this synchronized for ReentrantLock would give you the same time. The JEP itself recommends that you "avoid, where possible, doing I/O or other blocking operations while holding locks", and the reason now is contention.

JDK 21 already handled one case, the wait-sync-shared scenario: Object.wait(250) inside a shared monitor didn't pin on 21.0.12 (266.8 ms, 8 in flight, 0 events). JEP 491 explains why: the scheduler already compensates for Object.wait() by making sure a spare platform thread is available while the virtual thread waits.

The native call still pins the carrier, and JFR didn't flag it. On JDK 25, all 8 ffm-nanosleep tasks started within the first ~65 ms but finished one every ~250 ms (324, 575, 825 ... 2078 ms). With one carrier, that means each nanosleep held the entire carrier for the length of the call. The recording had exactly one jdk.VirtualThreadPinned event, and it didn't come from the native call:

jdk.VirtualThreadPinned {
  duration = 36.3 ms
  blockingOperation = "Object.wait"
  pinnedReason = "Waited for initialization of FfmSleep by another thread"
  carrierThread = "ForkJoinPool-1-worker-2" (javaThreadId = 36)

That event comes from the initialization of the FfmSleep class, one of the cases listed under "Future Work". The 2 s the carrier spent inside nanosleep produced no event at all. So "zero pinned events" doesn't prove nothing is holding a carrier. To find serialization, look at total time and throughput under concurrency. Searching the recording isn't enough.

Monitor instead of guessing

The tool is still JFR with the jdk.VirtualThreadPinned event. On JDK 25 it carries pinnedReason, blockingOperation and carrierThread, which are exactly the fields that answer "why didn't this virtual thread unmount?" Two commands cover it:

jfr summary app.jfr
jfr print --events jdk.VirtualThreadPinned --stack-depth 8 app.jfr

Before you trust the output, confirm the event was actually on. Oracle's documentation says it's enabled by default with a 20 ms threshold. That's not what we saw in this run. On JDK 21, with real pinning going on (2017.4 ms, one task at a time), a recording created through the API without enabling the event ended up with this:

 jdk.VirtualThreadPinned                     0             0

A jfr summary showing zero while the application was serialized end to end. With the event enabled explicitly, the same scenario gave 8:

rec.enable("jdk.VirtualThreadPinned").with("threshold", "10 ms").with("stackTrace", "true");
 jdk.VirtualThreadPinned                     8           112

From the command line, -XX:StartFlightRecording captured all 8 events with no extra configuration, and also with settings=default and settings=profile:

$JDK21/bin/java -Djdk.virtualThreadScheduler.parallelism=1 -XX:StartFlightRecording:filename=app.jfr -cp build21 PinningDemo sock-sync-permon raw

In practice, enable the event explicitly when the recording comes from the API, and check in jfr summary that the jdk.VirtualThreadPinned row is there before you conclude anything. Keep the threshold in mind too: a pin shorter than the threshold won't show up.

When native code sits on the hot path, having the event on doesn't help, as the nanosleep run showed. What works there is a thread dump of the carriers, available on both JDK 21 and 25:

jcmd <pid> Thread.dump_to_file -format=json carriers.json

Take it under load and it shows the carrier busy inside the downcall, which is exactly when JFR has nothing to say.

The old flag no longer helps. JEP 491 says of jdk.tracePinnedThreads: "We will therefore remove this system property; setting it on the command line will have no effect." In the same pinning scenario, run with -Djdk.tracePinnedThreads=full, JDK 21 prints the dump to stdout:

VirtualThread[#32,vt-0]/runnable@ForkJoinPool-1-worker-1 reason:MONITOR
    ...
    PinningDemo.runScenario(PinningDemo.java:132) <== monitors:1

JDK 25 prints nothing. On both JDKs, java -Djdk.tracePinnedThreads=full -version exits with code 0 and no warning. A diagnostic script that relied on this flag still "passes" on 25. It just doesn't check anything anymore.

When the old rule still holds

  • The service still runs on JDK 21. Look at the 21 column in the table: the rule applies in full there, and moving synchronized that guards long blocking over to ReentrantLock is still worth it.
  • There's a native call on the hot path. JNI or FFM, either one. The rule's concern still applies here, but its old remedy doesn't: nanosleep pinned the carrier with no lock involved. The fix is to move the call off the hot path or cap how many run at once, then confirm with the same total-time and throughput measurement.
  • There's heavy class initialization on the hot path. The only event in the native scenario was a virtual thread waiting 36.3 ms for another thread to initialize FfmSleep. If the first concurrent request triggers an expensive initialization, do it at application startup instead.

Recommendation

On JDK 25, DevDojo would retire the rule as written ("replace synchronized with ReentrantLock because of pinning") and put two rules in its place:

  1. Don't do I/O or long blocking while holding a shared lock, whatever kind of lock it is. The shared rows in the table serialize just the same on both JDKs.
  2. Treat native calls (JNI/FFM) and heavy class initialization on the hot path as suspected carrier pinning until a measurement shows otherwise. The old rule, or a variant of it, still applies to any service that meets one of the three conditions in the previous section.

Code already migrated to ReentrantLock stays as it is. The JEP says you don't need to revert it, and reverting just to follow the trend is a diff with nothing to show for it.

The test that settles it is to run the batch under concurrency and watch the clock. With a pinned carrier, the 8 tasks finish close to N × 250 ms and throughput drops to ~4 tasks/s. Without one, the same batch finishes close to 250 ms at ~30 tasks/s.

Next step

Pick the service that got that rule into your internal guide and compare its throughput with what the code path should deliver. Only after that should you touch the locks, and do it with the number in hand.

javavirtual-threadsconcurrency

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles