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
+4
View File
@@ -0,0 +1,4 @@
target/
*.class
.idea/
*.iml
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ankur Mhatre
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+70
View File
@@ -0,0 +1,70 @@
# virtual-threads-benchmark
Companion module for the ankurm.com post **"Virtual Threads on Spring Boot 4.1: The
Benchmarks, Re-Run, and the Pinning Advice That Expired."** Every number is from a real
concurrent load run against a real running embedded server on this JDK; every pinning claim is
verified against JDK 25's actual runtime behaviour, not carried forward from the JDK 21-era
post it replaces.
Lives in this container repo (not as its own top-level repository) alongside
[`../async`](../async), the companion module for the `@Async`-focused post, which already has a
dual-JDK JEP 491 pinning proof of its own (see the note near the bottom of this file).
## Versions (verified against `repo1.maven.org` maven-metadata.xml and the OpenJDK JEP pages, not aggregators)
| Component | Version | Notes |
|---|---|---|
| JDK | 25 (Temurin 25.0.4.1+1) | latest LTS; includes JEP 491 (GA in JDK 24) and JEP 506 (GA in JDK 25) |
| Spring Boot | 4.1.1 | latest GA at time of writing |
| Spring Framework | 7.0.9 | latest GA |
## Quickstart
```bash
./scripts/run-all.sh # regenerates every file in docs/output/ from a real test run
./scripts/run.sh # start on :8080, platform threads
./scripts/run.sh vt # start on :8080, spring.threads.virtual.enabled=true
```
Requires JDK 25 and Maven. First run must be online (Maven needs to fetch plugins into the
local cache); `-o` works for subsequent builds. This repo's own benchmark ran on a 2 vCPU
sandbox -- see [docs/02-benchmark-methodology.md](docs/02-benchmark-methodology.md) for why
that matters and how the numbers were still kept honest.
## What's demonstrated where
| Area | Source | Test | Transcript |
|---|---|---|---|
| `spring.threads.virtual.enabled` actually changes the request thread type | [`DemoController`](src/main/java/com/ankurm/vthreads/DemoController.java) | [`ThreadTypeTest`](src/test/java/com/ankurm/vthreads/ThreadTypeTest.java) | [`00`](docs/output/00-thread-type-confirmation.txt) |
| I/O-bound throughput: platform vs virtual threads, 600 concurrent | same | [`LoadBenchmarkTest`](src/test/java/com/ankurm/vthreads/LoadBenchmarkTest.java) | [`01`](docs/output/01-io-bound-benchmark.txt) |
| CPU-bound throughput: platform vs virtual threads, 60 concurrent (plus the JIT-warmup benchmarking bug this repo caught and fixed) | same | same | [`02`](docs/output/02-cpu-bound-benchmark.txt) |
| JEP 491 proof: `synchronized` no longer pins across a blocking sleep on JDK 24+ | [`PinningDemoService`](src/main/java/com/ankurm/vthreads/PinningDemoService.java) | [`PinningJep491Test`](src/test/java/com/ankurm/vthreads/PinningJep491Test.java) | [`03a`](docs/output/03a-pinning-jep491-proof.txt) |
| `-Djdk.tracePinnedThreads=full` is inert on JDK 25 | [`PinningTraceCheckMain`](src/main/java/com/ankurm/vthreads/PinningTraceCheckMain.java) | [`check-trace-pinned-threads-removed.sh`](scripts/check-trace-pinned-threads-removed.sh) | [`03b`](docs/output/03b-trace-pinned-threads-removed.txt) |
## Documentation chapters
1. [Enabling virtual threads on Spring Boot 4.1](docs/01-enabling-virtual-threads.md) -- the one
property, what it actually flips, and a `SpringApplicationBuilder` property-precedence trap
this repo's own benchmark hit
2. [Benchmark methodology and results](docs/02-benchmark-methodology.md) -- I/O-bound and
CPU-bound, on this sandbox's real hardware, including a JIT-warmup measurement bug caught
and fixed mid-build
3. [Pinning diagnosis, corrected for JEP 491](docs/03-pinning-diagnosis.md) -- why the standard
`synchronized`-pins-your-carrier advice, and the `-Djdk.tracePinnedThreads=full` flag every
article tells you to use, both stopped being true in JDK 24
4. [ScopedValue, the JDBC driver advice, and a production checklist](docs/04-scoped-value-and-checklist.md)
## A note on this module's relationship to `../async`
[`../async`](../async) is the companion module for a different, `@Async`-focused ankurm.com
post and already contains a dual-JDK (21.0.12.1 vs 25.0.4.1) proof of the same JEP 491 pinning
change, which is stronger evidence than this module can offer on its own (this module only had
JDK 25 available to test against). [Chapter 3](docs/03-pinning-diagnosis.md) here cross-links
to it rather than duplicating it. This module's own scope -- enabling the flag, the I/O/CPU
throughput benchmark, and the diagnosis flag's removal -- doesn't overlap with that module's
`@Async`-specific material, which is why it's kept as its own module here rather than merged
into `async/`.
## License
MIT -- see [LICENSE](LICENSE).
@@ -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
+48
View File
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>virtual-threads-boot4-demo</artifactId>
<version>1.0.0</version>
<name>virtual-threads-boot4-demo</name>
<description>Virtual threads on Spring Boot 4.1 / JDK 25, re-benchmarked, with a corrected pinning-diagnosis section for JEP 491</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Confirms -Djdk.tracePinnedThreads=full is inert on this JDK (JEP 491, GA in JDK 24).
# Pre-JDK-24, this flag made a pinned virtual thread print a stack trace to stdout while
# pinned. Runs PinningTraceCheckMain (which holds a monitor across Thread.sleep(200) on a
# virtual thread) once without the flag and once with it, and diffs stdout. Output:
# docs/output/03b-trace-pinned-threads-removed.txt. See docs/03-pinning-diagnosis.md.
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p docs/output
OUT=docs/output/03b-trace-pinned-threads-removed.txt
echo "java -version:" > "$OUT"
java -version 2>&1 | grep -v "^Picked up JAVA_TOOL_OPTIONS" >> "$OUT"
echo "" >> "$OUT"
echo "-- without -Djdk.tracePinnedThreads=full --" >> "$OUT"
java -cp target/classes com.ankurm.vthreads.PinningTraceCheckMain 2>&1 | grep -v "^Picked up JAVA_TOOL_OPTIONS" >> "$OUT"
echo "" >> "$OUT"
echo "-- with -Djdk.tracePinnedThreads=full --" >> "$OUT"
java -Djdk.tracePinnedThreads=full -cp target/classes com.ankurm.vthreads.PinningTraceCheckMain 2>&1 | grep -v "^Picked up JAVA_TOOL_OPTIONS" >> "$OUT"
echo "" >> "$OUT"
if grep -q "Thread\[#" "$OUT" && grep -qi "monitors:" "$OUT"; then
echo "RESULT: a pinned-thread stack trace WAS printed -- flag still functional on this JDK" >> "$OUT"
else
echo "RESULT: no pinned-thread stack trace was printed by either run -- the flag is inert on this JDK, consistent with JEP 491" >> "$OUT"
fi
cat "$OUT"
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Regenerates every file under docs/output/ from a real run: the JUnit test suite (which
# writes 00, 01, 02, 03a via the Transcript helper as it asserts) plus the standalone
# tracePinnedThreads check (03b, which needs its own JVM flag and isn't a JUnit test).
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -q -B test
./scripts/check-trace-pinned-threads-removed.sh > /dev/null
echo "Regenerated:"
ls -1 docs/output/
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Starts the app on :8080 to poke at by hand. Pass "vt" to enable virtual threads.
# ./scripts/run.sh -> platform threads (Boot default)
# ./scripts/run.sh vt -> spring.threads.virtual.enabled=true
set -euo pipefail
cd "$(dirname "$0")/.."
VT_FLAG="false"
if [ "${1:-}" = "vt" ]; then
VT_FLAG="true"
fi
mvn -q -B spring-boot:run -Dspring-boot.run.arguments="--spring.threads.virtual.enabled=${VT_FLAG}"
@@ -0,0 +1,39 @@
package com.ankurm.vthreads;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* Two endpoints used for the I/O-bound vs CPU-bound benchmark in
* docs/02-benchmark-methodology.md. /io simulates a blocking downstream call
* (JDBC, HTTP) with Thread.sleep. /cpu simulates real computation with a SHA-256
* loop so it cannot be optimized away and actually burns a core.
*/
@RestController
public class DemoController {
@GetMapping("/io")
public String io() throws InterruptedException {
Thread.sleep(300);
return "io:" + Thread.currentThread();
}
@GetMapping("/cpu")
public String cpu() throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] data = "spring-boot-4.1-virtual-threads-benchmark".getBytes();
for (int i = 0; i < 20_000; i++) {
data = md.digest(data);
}
return "cpu:" + Thread.currentThread() + ":" + data.length;
}
@GetMapping("/thread-info")
public String threadInfo() {
Thread t = Thread.currentThread();
return "Thread: " + t + " | Virtual: " + t.isVirtual();
}
}
@@ -0,0 +1,33 @@
package com.ankurm.vthreads;
/**
* Demonstrates JEP 491 (Synchronize Virtual Threads without Pinning, GA in JDK 24,
* so present in this repo's JDK 25). Before JEP 491, a virtual thread that entered a
* synchronized block or method and then blocked inside it (I/O, Thread.sleep, a
* monitor wait) could not unmount from its carrier thread -- it "pinned" the carrier
* for the entire blocking operation. As of JDK 24, ordinary monitor acquisition and
* blocking inside a synchronized region no longer pins. See docs/03-pinning-diagnosis.md
* for the primary source and the concurrency-based proof this repo runs.
*/
public class PinningDemoService {
/**
* Holds a monitor (via synchronized) across a blocking Thread.sleep call -- the
* textbook "this used to pin" example from every pre-JDK-24 virtual threads
* article, including the version of this post it replaces.
*/
public static synchronized void doWorkHoldingMonitor(long sleepMillis) throws InterruptedException {
Thread.sleep(sleepMillis);
}
/**
* Same operation, but synchronized on a distinct, per-call lock object so that
* concurrent callers never contend with each other for the *lock* itself. Any
* serialization observed is carrier-thread pinning, not lock contention.
*/
public static void doWorkHoldingDistinctMonitor(Object lock, long sleepMillis) throws InterruptedException {
synchronized (lock) {
Thread.sleep(sleepMillis);
}
}
}
@@ -0,0 +1,27 @@
package com.ankurm.vthreads;
/**
* Standalone (no Spring context) check that -Djdk.tracePinnedThreads=full is inert on this
* JDK. Pre-JDK-24, this flag made the JVM print a stack trace to stdout every time a virtual
* thread pinned its carrier. JEP 491 removed the property along with most of the pinning it
* used to report on: "setting it on the command line will have no effect." Run via
* scripts/check-trace-pinned-threads-removed.sh, which invokes this twice (with and without
* the flag) and diffs the output. See docs/03-pinning-diagnosis.md.
*/
public class PinningTraceCheckMain {
public static void main(String[] args) throws Exception {
System.out.println("java.version=" + System.getProperty("java.version"));
System.out.println("jdk.tracePinnedThreads=" + System.getProperty("jdk.tracePinnedThreads"));
System.out.println("Running a virtual thread that holds a monitor across Thread.sleep(200)...");
Thread vt = Thread.ofVirtual().start(() -> {
try {
PinningDemoService.doWorkHoldingMonitor(200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
vt.join();
System.out.println("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.");
}
}
@@ -0,0 +1,29 @@
package com.ankurm.vthreads;
/**
* Demonstrates the JDK 25 FINALIZED ScopedValue API (JEP 506, GA in JDK 25 -- no
* --enable-preview needed). The old preview-era static shortcut ScopedValue.runWhere(...)
* does not exist on this API; javap -p java.lang.ScopedValue against this JDK's runtime
* classes confirms it. The finalized shape returns a Carrier from where(...), with .run(...)
* or .call(...) on it. See docs/04-scoped-value-and-checklist.md.
*/
public class ScopedValueDemo {
record ExpensiveContext(String requestId) {}
private static final ScopedValue<ExpensiveContext> CTX = ScopedValue.newInstance();
public static String processRequest() {
// CTX is accessible here and in anything this method calls, because it's invoked
// from inside the ScopedValue.where(...).run(...) scope below.
return "handled " + CTX.get().requestId();
}
public static String runWithContext(String requestId) {
return ScopedValue.where(CTX, new ExpensiveContext(requestId))
.call(ScopedValueDemo::processRequest);
// CTX is automatically cleared once this scope exits -- no cleanup needed, and no
// stale value can leak to a later virtual thread the way a forgotten
// ThreadLocal.remove() can.
}
}
@@ -0,0 +1,11 @@
package com.ankurm.vthreads;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class VirtualThreadsBoot4DemoApplication {
public static void main(String[] args) {
SpringApplication.run(VirtualThreadsBoot4DemoApplication.class, args);
}
}
@@ -0,0 +1,19 @@
spring:
application:
name: virtual-threads-boot4-demo
threads:
virtual:
enabled: false
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,metrics
logging:
level:
com.ankurm.vthreads: INFO
@@ -0,0 +1,164 @@
package com.ankurm.vthreads;
import org.junit.jupiter.api.Test;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.server.context.WebServerInitializedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.context.ConfigurableApplicationContext;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Real concurrent-load comparison of platform threads (Boot's default embedded Tomcat pool)
* against virtual threads (spring.threads.virtual.enabled=true), against a real running
* embedded server, on the two endpoints in DemoController. This box is a 2 vCPU sandbox, not
* the 4-core/8GB machine the original 2025 post benchmarked on -- the absolute numbers below
* are honestly smaller and noisier than a real server's, but the SHAPE of the result (I/O-bound
* scales with virtual threads, CPU-bound does not) is the same physics either hardware runs on,
* and every number here comes from this run, not the old post's numbers copied forward.
*
* Output: docs/output/01-io-bound-benchmark.txt, docs/output/02-cpu-bound-benchmark.txt.
* See docs/02-benchmark-methodology.md.
*/
class LoadBenchmarkTest {
private record Result(int total, int success, long wallMs, double p50, double p99) {}
private Result fireConcurrent(String baseUrl, String path, int concurrency) throws Exception {
HttpClient client = HttpClient.newBuilder()
.executor(Executors.newVirtualThreadPerTaskExecutor())
.build();
List<Long> latencies = Collections.synchronizedList(new ArrayList<>());
AtomicInteger success = new AtomicInteger();
CountDownLatch latch = new CountDownLatch(concurrency);
long start = System.nanoTime();
for (int i = 0; i < concurrency; i++) {
Thread.ofVirtual().start(() -> {
long reqStart = System.nanoTime();
try {
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + path)).build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) {
success.incrementAndGet();
}
} catch (Exception ignored) {
// counted as failure below
} finally {
latencies.add((System.nanoTime() - reqStart) / 1_000_000);
latch.countDown();
}
});
}
latch.await(60, TimeUnit.SECONDS);
long wallMs = (System.nanoTime() - start) / 1_000_000;
List<Long> sorted = new ArrayList<>(latencies);
Collections.sort(sorted);
double p50 = sorted.isEmpty() ? 0 : sorted.get(sorted.size() / 2);
int p99Idx = sorted.isEmpty() ? 0 : Math.min(sorted.size() - 1, (int) (sorted.size() * 0.99));
double p99 = sorted.isEmpty() ? 0 : sorted.get(p99Idx);
return new Result(concurrency, success.get(), wallMs, p50, p99);
}
@Test
void ioBoundScenario() throws Exception {
int concurrency = 600; // > default Tomcat platform-thread pool of 200
Result platform = runAgainst(false, "/io", concurrency);
Result virtual = runAgainst(true, "/io", concurrency);
Transcript t = Transcript.start("01-io-bound-benchmark.txt",
"I/O-bound endpoint (/io, Thread.sleep(300)), concurrency=" + concurrency
+ ", 2 vCPU sandbox, Spring Boot 4.1.1 / JDK " + System.getProperty("java.version"));
t.line(String.format("platform threads : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
platform.total, platform.success, platform.wallMs, platform.p50, platform.p99));
t.line(String.format("virtual threads : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
virtual.total, virtual.success, virtual.wallMs, virtual.p50, virtual.p99));
t.blank();
t.line("default embedded Tomcat platform-thread pool = 200; with 600 concurrent 300ms-sleep");
t.line("requests, platform threads must queue in ~3 sequential batches, virtual threads do not.");
t.save();
// The real, checked claim: virtual threads finish this workload meaningfully faster
// wall-clock than the platform-thread pool, on THIS run, on THIS hardware.
assertThat(virtual.wallMs).isLessThan(platform.wallMs);
assertThat(virtual.success).isEqualTo(concurrency);
assertThat(platform.success).isEqualTo(concurrency);
}
@Test
void cpuBoundScenario() throws Exception {
int concurrency = 60; // well beyond 2 vCPUs -- forces real core-bound saturation for both models
Result platform = runAgainst(false, "/cpu", concurrency);
Result virtual = runAgainst(true, "/cpu", concurrency);
Transcript t = Transcript.start("02-cpu-bound-benchmark.txt",
"CPU-bound endpoint (/cpu, 20,000x SHA-256), concurrency=" + concurrency
+ ", 2 vCPU sandbox, Spring Boot 4.1.1 / JDK " + System.getProperty("java.version"));
t.line(String.format("platform threads : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
platform.total, platform.success, platform.wallMs, platform.p50, platform.p99));
t.line(String.format("virtual threads : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
virtual.total, virtual.success, virtual.wallMs, virtual.p50, virtual.p99));
t.blank();
t.line("a CPU-bound virtual thread never yields -- it stays mounted on its carrier for the full");
t.line("computation, so it competes for the same 2 physical cores a platform thread would. Expect");
t.line("these two wall times to be close, not virtual threads winning decisively as in the I/O case.");
t.save();
assertThat(platform.success).isEqualTo(concurrency);
assertThat(virtual.success).isEqualTo(concurrency);
// Not asserting virtual < platform here on purpose -- the whole point of this test is that
// CPU-bound work does NOT reliably favor either thread model. See docs/02.
}
private Result runAgainst(boolean virtualThreads, String path, int concurrency) throws Exception {
AtomicInteger capturedPort = new AtomicInteger(-1);
CountDownLatch portLatch = new CountDownLatch(1);
SpringApplicationBuilder builder = new SpringApplicationBuilder(VirtualThreadsBoot4DemoApplication.class)
.initializers(ctx -> ctx.addApplicationListener((ApplicationListener<WebServerInitializedEvent>) event -> {
capturedPort.set(event.getWebServer().getPort());
portLatch.countDown();
}));
// Passed as command-line-style args (highest property precedence) rather than
// builder.properties(...) (which binds to "defaultProperties" -- LOWEST precedence,
// so it was silently losing to the explicit false in application.yml. Real bug,
// caught because the test asserted the actual thread type instead of just that the
// app started.)
ConfigurableApplicationContext ctx = builder.run(
"--server.port=0",
"--spring.threads.virtual.enabled=" + virtualThreads,
"--spring.jmx.enabled=false");
try {
portLatch.await(10, TimeUnit.SECONDS);
String baseUrl = "http://localhost:" + capturedPort.get();
// Warm-up: JIT compiles hot methods (MessageDigest.digest, the servlet dispatch
// path) per-CLASS, not per-context, so within one JVM whichever scenario runs
// SECOND gets a free JIT head start that has nothing to do with thread model.
// First cut of this benchmark ran platform-threads first every time and
// attributed the resulting gap to virtual threads -- discovered by inspecting the
// CPU-bound numbers, where virtual threads had no right to win by 4x. Fire an
// untimed warm-up round through the SAME code paths before the real measurement.
fireConcurrent(baseUrl, path, Math.min(concurrency, 30));
return fireConcurrent(baseUrl, path, concurrency);
} finally {
ctx.close();
}
}
}
@@ -0,0 +1,64 @@
package com.ankurm.vthreads;
import org.junit.jupiter.api.Test;
import java.util.concurrent.CountDownLatch;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Proves JEP 491 (GA in JDK 24) empirically rather than citing it: N virtual threads
* each hold a DISTINCT monitor (via synchronized, on a lock object no other thread ever
* touches) across a blocking Thread.sleep. Before JDK 24 this pinned every one of them to
* a carrier thread for the full sleep duration, so wall time scaled with
* ceil(N / availableProcessors) * sleepMillis. On JDK 24+, it does not: wall time stays
* close to sleepMillis regardless of N or the carrier pool size, because the virtual
* thread unmounts from its carrier while sleeping even though it still holds the lock.
*
* Output: docs/output/03a-pinning-jep491-proof.txt. See docs/03-pinning-diagnosis.md.
*/
class PinningJep491Test {
@Test
void synchronizedNoLongerPinsAcrossBlockingSleep() throws InterruptedException {
int n = 60;
long sleepMillis = 250;
int carrierCount = Runtime.getRuntime().availableProcessors();
long wallTimeIfPinnedMs = ((n + carrierCount - 1) / carrierCount) * sleepMillis;
Transcript t = Transcript.start("03a-pinning-jep491-proof.txt",
"JEP 491 proof: synchronized held across Thread.sleep, JDK " + System.getProperty("java.version"));
t.line("availableProcessors (default virtual-thread carrier pool size) = " + carrierCount);
t.line("virtual threads = " + n + ", each holds a DISTINCT monitor for " + sleepMillis + "ms");
t.line("predicted wall time IF PINNED (pre-JDK-24 behaviour): ~" + wallTimeIfPinnedMs + "ms");
t.line("predicted wall time IF NOT PINNED (JDK 24+ behaviour): ~" + sleepMillis + "ms, independent of carrier count");
t.blank();
CountDownLatch latch = new CountDownLatch(n);
long start = System.nanoTime();
for (int i = 0; i < n; i++) {
Object myLock = new Object();
Thread.ofVirtual().start(() -> {
try {
PinningDemoService.doWorkHoldingDistinctMonitor(myLock, sleepMillis);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
latch.countDown();
}
});
}
latch.await();
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
t.line("actual wall time: " + elapsedMs + "ms");
t.line("verdict: " + (elapsedMs < wallTimeIfPinnedMs / 2
? "NOT PINNED -- matches JEP 491's documented JDK 24+ behaviour"
: "PINNED -- matches pre-JDK-24 behaviour"));
t.save();
// The real assertion: this build fails the day this stops being true on whatever
// JDK runs it, e.g. if run on JDK < 24.
assertThat(elapsedMs).isLessThan(wallTimeIfPinnedMs / 2);
}
}
@@ -0,0 +1,27 @@
package com.ankurm.vthreads;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Confirms the finalized JDK 25 ScopedValue API (JEP 506) compiles and runs with no
* --enable-preview flag, and that ScopedValue.where(...).call(...) actually threads the value
* through to a method called from inside the scope. Output:
* docs/output/03c-scoped-value-finalized.txt.
*/
class ScopedValueTest {
@Test
void whereRunThreadsContextThroughToCalledMethod() {
Transcript t = Transcript.start("03c-scoped-value-finalized.txt",
"ScopedValue (JEP 506, finalized JDK 25), JDK " + System.getProperty("java.version"));
String result = ScopedValueDemo.runWithContext("req-42");
t.line("ScopedValue.where(CTX, ctx).call(ScopedValueDemo::processRequest) -> " + result);
t.line("no --enable-preview flag used on this run");
t.save();
assertThat(result).isEqualTo("handled req-42");
}
}
@@ -0,0 +1,65 @@
package com.ankurm.vthreads;
import org.junit.jupiter.api.Test;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.server.context.WebServerInitializedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.web.client.RestClient;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Confirms spring.threads.virtual.enabled actually changes the request-handling thread type,
* against a real running embedded server (not a description). Output:
* docs/output/00-thread-type-confirmation.txt.
*/
class ThreadTypeTest {
@Test
void flagTogglesRequestThreadType() throws Exception {
String withoutFlag = hit(false);
String withFlag = hit(true);
Transcript t = Transcript.start("00-thread-type-confirmation.txt",
"GET /thread-info with spring.threads.virtual.enabled=false vs true");
t.line("enabled=false -> " + withoutFlag);
t.line("enabled=true -> " + withFlag);
t.save();
assertThat(withoutFlag).contains("Virtual: false");
assertThat(withFlag).contains("Virtual: true");
}
private String hit(boolean virtualThreads) throws Exception {
AtomicInteger capturedPort = new AtomicInteger(-1);
CountDownLatch portLatch = new CountDownLatch(1);
SpringApplicationBuilder builder = new SpringApplicationBuilder(VirtualThreadsBoot4DemoApplication.class)
.initializers(ctx -> ctx.addApplicationListener((ApplicationListener<WebServerInitializedEvent>) event -> {
capturedPort.set(event.getWebServer().getPort());
portLatch.countDown();
}));
// Passed as command-line-style args (highest property precedence) rather than
// builder.properties(...) (which binds to "defaultProperties" -- LOWEST precedence,
// so it was silently losing to the explicit false in application.yml. Real bug,
// caught because the test asserted the actual thread type instead of just that the
// app started.)
ConfigurableApplicationContext ctx = builder.run(
"--server.port=0",
"--spring.threads.virtual.enabled=" + virtualThreads,
"--spring.jmx.enabled=false");
try {
portLatch.await(10, TimeUnit.SECONDS);
RestClient rest = RestClient.create("http://localhost:" + capturedPort.get());
return rest.get().uri("/thread-info").retrieve().body(String.class);
} finally {
ctx.close();
}
}
}
@@ -0,0 +1,50 @@
package com.ankurm.vthreads;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
/**
* Writes docs/output/NN-*.txt while a test runs, so every number quoted in the blog post
* is backed by a file produced by an assertion that would fail the build if it stopped
* being true. Never hand-edit files under docs/output/ — regenerate with scripts/run-all.sh.
*/
public final class Transcript {
private final StringBuilder buf = new StringBuilder();
private final Path outFile;
private Transcript(String fileName) {
this.outFile = Paths.get("docs/output", fileName);
}
public static Transcript start(String fileName, String header) {
Transcript t = new Transcript(fileName);
t.line(header);
t.line("=".repeat(header.length()));
return t;
}
public Transcript line(String s) {
buf.append(s).append('\n');
return this;
}
public Transcript blank() {
buf.append('\n');
return this;
}
public void save() {
try {
Files.createDirectories(outFile.getParent());
Files.writeString(outFile, buf.toString(), StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}