Files
asmhatre f506b01389 Add virtual-threads-benchmark: re-run Spring Boot 4.1 / JDK 25 benchmarks, JEP 491 pinning fixed
Companion module for the rewritten post 'Virtual Threads on Spring Boot 4.1: The Benchmarks,
Re-Run, and the Pinning Advice That Expired', retitled and re-benchmarked on Boot 4.1.1 /
JDK 25.0.4.1 (the original post was written against Boot 3.4 / JDK 21). Covers: I/O-bound and
CPU-bound throughput (platform vs virtual threads, including a JIT-warmup benchmarking bug this
build caught and fixed), JEP 491 proof that synchronized no longer pins a virtual thread's
carrier across a blocking call as of JDK 24 (obsoleting the old avoid-synchronized advice),
proof that -Djdk.tracePinnedThreads=full is inert on JDK 25, and JEP 506's finalized ScopedValue
API (JDK 25 GA, no --enable-preview, and a different shape than the old preview API). Kept as
its own module rather than a new top-level repository, alongside the existing async/ module,
which already has a stronger dual-JDK JEP 491 proof that this module's docs cross-link to
instead of duplicating.
2026-09-18 08:52:59 +00:00

6.5 KiB

3. Pinning diagnosis, corrected for JEP 491

Previous: 02-benchmark-methodology.md | README | Next: 04-scoped-value-and-checklist.md

Source: PinningDemoService.java, PinningTraceCheckMain.java. Test: PinningJep491Test.java. Script: scripts/check-trace-pinned-threads-removed.sh. Transcripts: docs/output/03a-pinning-jep491-proof.txt, docs/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" (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, for the article @Async in Spring Boot 4: Executors, Virtual Threads and the Self-Invocation Trap, 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: 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 -Djdk.tracePinnedThreads=full from your flags -- it does nothing -- and capture the jdk.VirtualThreadPinned JFR event instead (jcmd <pid> JFR.start or a startup -XX:StartFlightRecording). If your JDBC driver documentation still warns about synchronized-based pinning from before your driver's JDK 24 testing, verify against your actual JDK version before believing it.

Next: 04-scoped-value-and-checklist.md.