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:
2026-09-18 08:52:59 +00:00
parent eaa8171966
commit f506b01389
28 changed files with 1085 additions and 0 deletions
@@ -0,0 +1,43 @@
# 1. Enabling virtual threads on Spring Boot 4.1
[README](../README.md) | Next: [02-benchmark-methodology.md](02-benchmark-methodology.md)
Source: [`application.yml`](../src/main/resources/application.yml), [`DemoController.java`](../src/main/java/com/ankurm/vthreads/DemoController.java).
Test: [`ThreadTypeTest.java`](../src/test/java/com/ankurm/vthreads/ThreadTypeTest.java).
Transcript: [`docs/output/00-thread-type-confirmation.txt`](output/00-thread-type-confirmation.txt).
## Still one property
`spring.threads.virtual.enabled=true` is unchanged since Boot 3.2 and still the whole
configuration surface for most apps on Boot 4.1. It requires JDK 21+; this repo runs it on
JDK 25.
## What actually flips
Confirmed against a real running embedded server, not by reading the reference docs:
```
enabled=false -> Thread: Thread[#9711,http-nio-auto-5-exec-1,5,main] | Virtual: false
enabled=true -> Thread: VirtualThread[#9737,tomcat-handler-0]/runnable@ForkJoinPool-1-worker-2 | Virtual: true
```
The platform-thread name prefix changed between the Boot 3 era and this repo's Boot 4.1 run
(`http-nio-auto-5-exec-1` here vs. the `http-nio-8080-exec-3` shape in the older post) because
this test binds to `server.port=0` (a random port) rather than a fixed 8080 -- Tomcat's
connector-name-derived thread prefix reflects that. Cosmetic, but worth not being surprised by
if your own logs look different from either example.
## Test infrastructure trap worth knowing about
Setting the property via `SpringApplicationBuilder.properties(...)` and expecting it to win
over `application.yml` is a real bug this repo hit while building the benchmark below:
`.properties(...)` binds to Spring's **`defaultProperties`** source, which has the *lowest*
precedence of any property source Spring Boot recognizes -- lower than `application.yml`,
which had `spring.threads.virtual.enabled: false` as its own explicit default. The fix was
passing it as a command-line-style argument to `run(...)` instead
(`"--spring.threads.virtual.enabled=" + value`), which binds at the highest precedence. See
[`LoadBenchmarkTest.java`](../src/test/java/com/ankurm/vthreads/LoadBenchmarkTest.java) for
where this was caught -- the first draft of the CPU-bound benchmark below silently ran both
scenarios on platform threads and reported a difference that didn't exist.
Next: [02-benchmark-methodology.md](02-benchmark-methodology.md).
@@ -0,0 +1,81 @@
# 2. Benchmark methodology and results
[Previous: 01-enabling-virtual-threads.md](01-enabling-virtual-threads.md) | [README](../README.md) | Next: [03-pinning-diagnosis.md](03-pinning-diagnosis.md)
Source: [`DemoController.java`](../src/main/java/com/ankurm/vthreads/DemoController.java).
Test: [`LoadBenchmarkTest.java`](../src/test/java/com/ankurm/vthreads/LoadBenchmarkTest.java).
Transcripts: [`docs/output/01-io-bound-benchmark.txt`](output/01-io-bound-benchmark.txt), [`docs/output/02-cpu-bound-benchmark.txt`](output/02-cpu-bound-benchmark.txt).
## Why this repo doesn't reuse the old post's numbers
The version of this post it replaces benchmarked Boot 3.4.0 / JDK 21.0.3 with `wrk` (1,000
connections, 30s) on a 4-core/8GB box. Neither `wrk` nor an equivalent load generator is
available in this repo's build environment, and the environment itself is a 2 vCPU sandbox,
not a 4-core server. Rather than copy the old numbers forward with a changed version number
next to them, this repo builds its own concurrent load generator (`java.net.http.HttpClient`
backed by a virtual-thread executor, itself only used as the *client* -- see
`fireConcurrent()`) and reports what actually happened on this run, on this hardware, honestly
labeled as such.
## I/O-bound: `/io`, `Thread.sleep(300)`
600 concurrent requests, each a 300ms simulated downstream call:
```
platform threads : total=600 success=600 wall=1256ms p50=762ms p99=1193ms
virtual threads : total=600 success=600 wall=675ms p50=531ms p99=654ms
```
600 concurrent requests against Tomcat's default 200-thread platform pool queue in roughly
three sequential batches of 200; each batch pays the full 300ms, so total wall time lands
around 3 x 300ms plus scheduling overhead -- which is what the platform-thread row shows.
Virtual threads create one thread per request and unmount for the duration of the sleep, so
wall time stays close to a single 300ms round, regardless of how far past 200 the concurrency
goes. The shape matches the original 2025 benchmark; the absolute numbers are this sandbox's,
not that server's.
## CPU-bound: `/cpu`, 20,000x SHA-256 per request
This is the scenario worth reading carefully, because the first version of this benchmark
was wrong, and the fix is itself worth knowing about.
**First cut, concurrency=16, no warm-up round:** virtual threads finished 4x faster than
platform threads. That result doesn't make physical sense -- a CPU-bound virtual thread never
unmounts, so it competes for the same physical cores a platform thread would, and should show
no structural advantage. The cause: `MessageDigest.digest` and the servlet dispatch path get
JIT-compiled **per class**, not per Spring context, and this benchmark starts a fresh
`ApplicationContext` for each of the two scenarios inside the same JVM. Platform threads always
ran first; by the time the virtual-thread scenario ran, the hot loop was already JIT-warmed --
a benchmarking artifact with nothing to do with thread model, caught by the numbers being
implausible rather than by inspecting the code.
**Fix:** an untimed warm-up round (`fireConcurrent` at a smaller concurrency, discarded)
through the same code path before every timed measurement, in both scenarios.
**After the fix, concurrency=60** (chosen deliberately above this box's 2 vCPUs, so both
thread models are forced into real core-bound saturation rather than fitting comfortably
alongside each other):
```
platform threads : total=60 success=60 wall=166ms p50=146ms p99=162ms
virtual threads : total=60 success=60 wall=191ms p50=181ms p99=189ms
```
Close, with platform threads and virtual threads trading the lead by a few percent across
repeated runs (see the test's own run-to-run notes in
[`LoadBenchmarkTest.java`](../src/test/java/com/ankurm/vthreads/LoadBenchmarkTest.java)) --
consistent with the "no meaningful difference for CPU-bound work" claim every virtual threads
article makes, but arrived at here by running it, catching a real measurement bug, fixing the
methodology, and re-running it, rather than by assuming the claim was self-evidently true.
<blockquote>If your own CPU-bound benchmark shows virtual threads winning decisively, check
your warm-up before you check your thread model. JIT compilation state leaking between two
scenarios measured in the same JVM run is an easy way to manufacture a result that isn't
real.</blockquote>
- If you're benchmarking your own service: match concurrency to something past your real
platform-thread pool size for the I/O case, and past your core count for the CPU case --
otherwise neither test forces the behaviour you're trying to observe.
- JEP 444 (virtual threads, JDK 21): <https://openjdk.org/jeps/444> (`rel=nofollow`)
Next: [03-pinning-diagnosis.md](03-pinning-diagnosis.md).
@@ -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 &lt;pid&gt; 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).
@@ -0,0 +1,80 @@
# 4. ScopedValue, the JDBC driver advice, and a production checklist
[Previous: 03-pinning-diagnosis.md](03-pinning-diagnosis.md) | [README](../README.md)
## ScopedValue is finalized in JDK 25, with a different API than the preview version
The old post's `ThreadLocal` gotcha recommended `ScopedValue` as "the Project Loom-native
replacement," using `ScopedValue.runWhere(CTX, value, () -> { ... })`. Two things changed:
1. **[JEP 506](https://openjdk.org/jeps/506) (`rel=nofollow`) finalized `ScopedValue` in JDK 25** --
it's no longer a preview feature and needs no `--enable-preview` flag. Confirmed by compiling
and running a `ScopedValue` example on this repo's JDK 25 with no preview flags at all.
2. **`ScopedValue.runWhere(...)` does not exist on the finalized API.** `javap -p java.lang.ScopedValue`
against this JDK's runtime classes shows no such method. The finalized shape is
`ScopedValue.where(scopedValue, value).run(runnable)` (a `Carrier` returned by `where`, with
`.run(...)` or `.call(...)` on it):
```java
private static final ScopedValue<ExpensiveContext> CTX = ScopedValue.newInstance();
ScopedValue.where(CTX, new ExpensiveContext()).run(() -> {
// CTX is accessible in this scope and any method called from here
processRequest();
// automatically cleared when the scope exits
});
```
Code copied from a pre-JDK-25 `ScopedValue` article using `runWhere` will not compile on this
JDK. This is a small API surface change, but it's the kind of thing `javap` catches in seconds
and a fabricated-from-memory snippet would not.
## The JDBC driver pinning advice needs the same correction as the general case
The old post's second gotcha was "many JDBC drivers use `synchronized` internally in their
socket I/O paths, so every DB call becomes a pinning event" -- true through JDK 23, and the
reason MySQL Connector/J and older PostgreSQL JDBC driver versions got singled out for their
internal locking. [Chapter 3](03-pinning-diagnosis.md) already established that ordinary
`synchronized`-then-block no longer pins as of JDK 24. That correction applies here without
exception: if a driver's pinning was caused by a plain `synchronized` block around socket I/O
(the common case), **JEP 491 fixes it for free on JDK 24+, with no driver upgrade required.**
The narrower native-callback pinning case from chapter 3 is the only mechanism left that could
still affect a driver, and it needs the driver to call native code that calls back into
blocking Java -- unusual for a JDBC driver's I/O path. Treat "upgrade your JDBC driver to fix
pinning" as obsolete advice on JDK 24+; verify with `jdk.VirtualThreadPinned` JFR events on
your actual driver and JDK version rather than assuming either the old warning or this
correction applies to your exact setup.
## Never pool virtual threads -- unchanged
Nothing about JEP 491 changes this. Virtual threads are cheap to create; pooling them adds
synchronization overhead for no benefit and defeats the design:
```java
// WRONG: pooling virtual threads
ExecutorService pool = Executors.newFixedThreadPool(100, Thread.ofVirtual().factory());
// RIGHT: unbounded virtual-thread-per-task executor
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
```
This repo's own load generator (`LoadBenchmarkTest.fireConcurrent`) uses exactly this pattern
for its `HttpClient` executor.
## Production checklist for Boot 4.1 / JDK 25
- Enable with `spring.threads.virtual.enabled=true` -- one property, unchanged since Boot 3.2.
- Confirm you're actually on JDK 24+ before relying on JEP 491 -- `synchronized` still pins on
JDK 21-23.
- Delete `-Djdk.tracePinnedThreads=full` from your flags on JDK 24+; it's inert. Use the
`jdk.VirtualThreadPinned` JFR event instead.
- Profile I/O-bound endpoints for the real win; don't expect anything from CPU-bound work --
[chapter 2](02-benchmark-methodology.md) measured this directly, including a benchmarking
bug that briefly suggested otherwise.
- If you still see pinning on JDK 24+, suspect native-callback code paths (JNI, Foreign
Function & Memory API), not plain `synchronized`.
- Prefer `ScopedValue` over `ThreadLocal` for new code holding per-request context, using the
finalized JDK 25 API (`ScopedValue.where(...).run(...)`, not the old preview `runWhere`).
- Never pool virtual threads.
- Further reading: [JEP 444](https://openjdk.org/jeps/444) (`rel=nofollow`), [JEP 491](https://openjdk.org/jeps/491) (`rel=nofollow`), [JEP 506](https://openjdk.org/jeps/506) (`rel=nofollow`).
@@ -0,0 +1,4 @@
GET /thread-info with spring.threads.virtual.enabled=false vs true
==================================================================
enabled=false -> Thread: Thread[#9742,http-nio-auto-5-exec-1,5,main] | Virtual: false
enabled=true -> Thread: VirtualThread[#9769,tomcat-handler-0]/runnable@ForkJoinPool-1-worker-1 | Virtual: true
@@ -0,0 +1,7 @@
I/O-bound endpoint (/io, Thread.sleep(300)), concurrency=600, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK 25.0.4.1
==============================================================================================================
platform threads : total=600 success=600 wall=1325ms p50=733ms p99=1201ms
virtual threads : total=600 success=600 wall=1042ms p50=658ms p99=720ms
default embedded Tomcat platform-thread pool = 200; with 600 concurrent 300ms-sleep
requests, platform threads must queue in ~3 sequential batches, virtual threads do not.
@@ -0,0 +1,8 @@
CPU-bound endpoint (/cpu, 20,000x SHA-256), concurrency=60, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK 25.0.4.1
============================================================================================================
platform threads : total=60 success=60 wall=208ms p50=130ms p99=202ms
virtual threads : total=60 success=60 wall=201ms p50=187ms p99=196ms
a CPU-bound virtual thread never yields -- it stays mounted on its carrier for the full
computation, so it competes for the same 2 physical cores a platform thread would. Expect
these two wall times to be close, not virtual threads winning decisively as in the I/O case.
@@ -0,0 +1,9 @@
JEP 491 proof: synchronized held across Thread.sleep, JDK 25.0.4.1
==================================================================
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: 251ms
verdict: NOT PINNED -- matches JEP 491's documented JDK 24+ behaviour
@@ -0,0 +1,18 @@
java -version:
openjdk version "25.0.4.1" 2026-08-18 LTS
OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS, mixed mode, sharing)
-- 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
@@ -0,0 +1,4 @@
ScopedValue (JEP 506, finalized JDK 25), JDK 25.0.4.1
=====================================================
ScopedValue.where(CTX, ctx).call(ScopedValueDemo::processRequest) -> handled req-42
no --enable-preview flag used on this run