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.
105 lines
6.5 KiB
Markdown
105 lines
6.5 KiB
Markdown
# 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.
|
|
|
|
<blockquote><strong>If you're diagnosing a suspected pinning problem on JDK 24+,</strong>
|
|
delete <code>-Djdk.tracePinnedThreads=full</code> from your flags -- it does nothing -- and
|
|
capture the <code>jdk.VirtualThreadPinned</code> JFR event instead
|
|
(<code>jcmd <pid> JFR.start</code> or a startup <code>-XX:StartFlightRecording</code>).
|
|
If your JDBC driver documentation still warns about <code>synchronized</code>-based pinning
|
|
from before your driver's JDK 24 testing, verify against your actual JDK version before
|
|
believing it.</blockquote>
|
|
|
|
- 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`)
|
|
|
|
Next: [04-scoped-value-and-checklist.md](04-scoped-value-and-checklist.md).
|