# 3. Pinning diagnosis, corrected for JEP 491 [Previous: 02-benchmark-methodology.md](02-benchmark-methodology.md) | [README](../README.md) | Next: [04-scoped-value-and-checklist.md](04-scoped-value-and-checklist.md) Source: [`PinningDemoService.java`](../src/main/java/com/ankurm/vthreads/PinningDemoService.java), [`PinningTraceCheckMain.java`](../src/main/java/com/ankurm/vthreads/PinningTraceCheckMain.java). Test: [`PinningJep491Test.java`](../src/test/java/com/ankurm/vthreads/PinningJep491Test.java). Script: [`scripts/check-trace-pinned-threads-removed.sh`](../scripts/check-trace-pinned-threads-removed.sh). Transcripts: [`docs/output/03a-pinning-jep491-proof.txt`](output/03a-pinning-jep491-proof.txt), [`docs/output/03b-trace-pinned-threads-removed.txt`](output/03b-trace-pinned-threads-removed.txt). ## The thing every virtual threads article says, that stopped being true in JDK 24 Every pre-JDK-24 article about virtual threads -- including the version of this post it replaces -- has some version of: "a virtual thread that enters a `synchronized` block and then blocks inside it cannot unmount; it pins its carrier thread for the whole operation." That was true through JDK 23. [JEP 491, "Synchronize Virtual Threads without Pinning"](https://openjdk.org/jeps/491) (`rel=nofollow`) shipped GA in **JDK 24** and changed it: as of JDK 24, ordinary `synchronized` blocks and methods, and `Object.wait()`, no longer pin. This repo runs JDK 25, so it inherits that behaviour, and the old advice is now wrong for anyone on JDK 24+. A stronger version of this same proof already exists in a sibling ankurm.com companion repo: [`spring-async-demo/async`](https://ankurm.com/git.app/asmhatre/spring-async-demo/src/branch/main/async/docs/08-virtual-threads-and-pinning.md), for the article [@Async in Spring Boot 4: Executors, Virtual Threads and the Self-Invocation Trap](https://ankurm.com/spring-boot-4-async-executors-virtual-threads/), runs the **identical class file on two different JDKs** -- 21.0.12.1 and 25.0.4.1 -- and shows the same guarded run going from 4806ms (matching the pinned prediction almost exactly) to 301ms. This repo only had JDK 25 available to test against, so its own proof below is a single-JDK version of the same result; if you want the direct before/after on two real JDK installs, that's the one to read. ## Proving it rather than citing it The JEP text is the primary source, but the house standard here is a real run, not a citation. `PinningJep491Test` starts 60 virtual threads, each entering `synchronized` on its own, **distinct** lock object (so no thread ever contends with another for the lock itself -- any serialization observed is carrier-thread pinning, not ordinary lock contention), then sleeping 250ms while still holding it: ``` availableProcessors (default virtual-thread carrier pool size) = 2 virtual threads = 60, each holds a DISTINCT monitor for 250ms predicted wall time IF PINNED (pre-JDK-24 behaviour): ~7500ms predicted wall time IF NOT PINNED (JDK 24+ behaviour): ~250ms, independent of carrier count actual wall time: 253ms verdict: NOT PINNED -- matches JEP 491's documented JDK 24+ behaviour ``` If this ran on JDK 23 or earlier, 60 virtual threads sharing a 2-thread carrier pool while each pins for 250ms would serialize into 30 sequential batches: roughly 7.5 seconds. It took 253ms. That's not "a bit better" -- it's the entire pinning cost gone for this pattern. ## The diagnostic flag from every one of those articles no longer does anything `-Djdk.tracePinnedThreads=full` was the standard way to find pinning: pre-JDK-24, the JVM printed a stack trace to stdout every time a virtual thread pinned. JEP 491 removed the property along with most of the pinning it used to report on. `scripts/check-trace-pinned-threads-removed.sh` runs the same synchronized-then-sleep scenario twice, once with the flag and once without, and diffs the output: ``` -- without -Djdk.tracePinnedThreads=full -- java.version=25.0.4.1 jdk.tracePinnedThreads=null Running a virtual thread that holds a monitor across Thread.sleep(200)... Done. If jdk.tracePinnedThreads still worked on this JDK, a pinned-thread stack trace would have printed above while the virtual thread was inside doWorkHoldingMonitor. -- with -Djdk.tracePinnedThreads=full -- java.version=25.0.4.1 jdk.tracePinnedThreads=full Running a virtual thread that holds a monitor across Thread.sleep(200)... Done. If jdk.tracePinnedThreads still worked on this JDK, a pinned-thread stack trace would have printed above while the virtual thread was inside doWorkHoldingMonitor. RESULT: no pinned-thread stack trace was printed by either run -- the flag is inert on this JDK, consistent with JEP 491 ``` Setting the property has no effect at all on JDK 25 -- not an error, not a deprecation warning, just silence either way. A team that kept `-Djdk.tracePinnedThreads=full` in their JVM flags through a JDK upgrade would get zero signal from it going forward and might not notice. ## What replaces it JEP 491 kept the `jdk.VirtualThreadPinned` JFR event for the pinning that's still possible -- it now reports both the pinning reason and the carrier thread's identity. The remaining case, [per the JEP itself](https://openjdk.org/jeps/491): a virtual thread that calls native code (a native method, or the Foreign Function & Memory API) which itself calls back into Java code that blocks or synchronizes. That's a narrower trigger than "any `synchronized` plus I/O" and one this repo does not attempt to reproduce -- constructing a real native-callback pinning case needs JNI, which is out of scope for a Spring Boot demo. Treat it as: still real, still worth the JFR event, much rarer in ordinary application code than the blanket old advice implied.
If you're diagnosing a suspected pinning problem on JDK 24+, delete- JEP 491 in full, including the exact wording on what still pins:-Djdk.tracePinnedThreads=fullfrom your flags -- it does nothing -- and capture thejdk.VirtualThreadPinnedJFR event instead (jcmd <pid> JFR.startor a startup-XX:StartFlightRecording). If your JDBC driver documentation still warns aboutsynchronized-based pinning from before your driver's JDK 24 testing, verify against your actual JDK version before believing it.