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,4 @@
|
|||||||
|
target/
|
||||||
|
*.class
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
@@ -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.
|
||||||
@@ -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 <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).
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
Executable
+12
@@ -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/
|
||||||
Executable
+13
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user