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.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user