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.
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=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.
- JEP 491 in full, including the exact wording on what still pins:
https://openjdk.org/jeps/491 (
rel=nofollow) - JEP 444 (virtual threads, JDK 21 baseline): https://openjdk.org/jeps/444 (
rel=nofollow)