diff --git a/virtual-threads-benchmark/.gitignore b/virtual-threads-benchmark/.gitignore
new file mode 100644
index 0000000..b2e433c
--- /dev/null
+++ b/virtual-threads-benchmark/.gitignore
@@ -0,0 +1,4 @@
+target/
+*.class
+.idea/
+*.iml
diff --git a/virtual-threads-benchmark/LICENSE b/virtual-threads-benchmark/LICENSE
new file mode 100644
index 0000000..aa5473f
--- /dev/null
+++ b/virtual-threads-benchmark/LICENSE
@@ -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.
diff --git a/virtual-threads-benchmark/README.md b/virtual-threads-benchmark/README.md
new file mode 100644
index 0000000..bd05c10
--- /dev/null
+++ b/virtual-threads-benchmark/README.md
@@ -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).
diff --git a/virtual-threads-benchmark/docs/01-enabling-virtual-threads.md b/virtual-threads-benchmark/docs/01-enabling-virtual-threads.md
new file mode 100644
index 0000000..b3aa028
--- /dev/null
+++ b/virtual-threads-benchmark/docs/01-enabling-virtual-threads.md
@@ -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).
diff --git a/virtual-threads-benchmark/docs/02-benchmark-methodology.md b/virtual-threads-benchmark/docs/02-benchmark-methodology.md
new file mode 100644
index 0000000..06883a3
--- /dev/null
+++ b/virtual-threads-benchmark/docs/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.
+
+
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.
+
+- 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): (`rel=nofollow`)
+
+Next: [03-pinning-diagnosis.md](03-pinning-diagnosis.md).
diff --git a/virtual-threads-benchmark/docs/03-pinning-diagnosis.md b/virtual-threads-benchmark/docs/03-pinning-diagnosis.md
new file mode 100644
index 0000000..16b4a86
--- /dev/null
+++ b/virtual-threads-benchmark/docs/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.
+
+If you're diagnosing a suspected pinning problem on JDK 24+,
+delete -Djdk.tracePinnedThreads=full from your flags -- it does nothing -- and
+capture the jdk.VirtualThreadPinned JFR event instead
+(jcmd <pid> JFR.start or a startup -XX:StartFlightRecording).
+If your JDBC driver documentation still warns about synchronized-based pinning
+from before your driver's JDK 24 testing, verify against your actual JDK version before
+believing it.
+
+- JEP 491 in full, including the exact wording on what still pins:
+ (`rel=nofollow`)
+- JEP 444 (virtual threads, JDK 21 baseline): (`rel=nofollow`)
+
+Next: [04-scoped-value-and-checklist.md](04-scoped-value-and-checklist.md).
diff --git a/virtual-threads-benchmark/docs/04-scoped-value-and-checklist.md b/virtual-threads-benchmark/docs/04-scoped-value-and-checklist.md
new file mode 100644
index 0000000..4f0443e
--- /dev/null
+++ b/virtual-threads-benchmark/docs/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 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`).
diff --git a/virtual-threads-benchmark/docs/output/00-thread-type-confirmation.txt b/virtual-threads-benchmark/docs/output/00-thread-type-confirmation.txt
new file mode 100644
index 0000000..a56ed80
--- /dev/null
+++ b/virtual-threads-benchmark/docs/output/00-thread-type-confirmation.txt
@@ -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
diff --git a/virtual-threads-benchmark/docs/output/01-io-bound-benchmark.txt b/virtual-threads-benchmark/docs/output/01-io-bound-benchmark.txt
new file mode 100644
index 0000000..b307423
--- /dev/null
+++ b/virtual-threads-benchmark/docs/output/01-io-bound-benchmark.txt
@@ -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.
diff --git a/virtual-threads-benchmark/docs/output/02-cpu-bound-benchmark.txt b/virtual-threads-benchmark/docs/output/02-cpu-bound-benchmark.txt
new file mode 100644
index 0000000..ac97711
--- /dev/null
+++ b/virtual-threads-benchmark/docs/output/02-cpu-bound-benchmark.txt
@@ -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.
diff --git a/virtual-threads-benchmark/docs/output/03a-pinning-jep491-proof.txt b/virtual-threads-benchmark/docs/output/03a-pinning-jep491-proof.txt
new file mode 100644
index 0000000..0435f17
--- /dev/null
+++ b/virtual-threads-benchmark/docs/output/03a-pinning-jep491-proof.txt
@@ -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
diff --git a/virtual-threads-benchmark/docs/output/03b-trace-pinned-threads-removed.txt b/virtual-threads-benchmark/docs/output/03b-trace-pinned-threads-removed.txt
new file mode 100644
index 0000000..c953bb7
--- /dev/null
+++ b/virtual-threads-benchmark/docs/output/03b-trace-pinned-threads-removed.txt
@@ -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
diff --git a/virtual-threads-benchmark/docs/output/03c-scoped-value-finalized.txt b/virtual-threads-benchmark/docs/output/03c-scoped-value-finalized.txt
new file mode 100644
index 0000000..40c649e
--- /dev/null
+++ b/virtual-threads-benchmark/docs/output/03c-scoped-value-finalized.txt
@@ -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
diff --git a/virtual-threads-benchmark/pom.xml b/virtual-threads-benchmark/pom.xml
new file mode 100644
index 0000000..39a85d3
--- /dev/null
+++ b/virtual-threads-benchmark/pom.xml
@@ -0,0 +1,48 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.1.1
+
+
+
+ com.ankurm
+ virtual-threads-boot4-demo
+ 1.0.0
+ virtual-threads-boot4-demo
+ Virtual threads on Spring Boot 4.1 / JDK 25, re-benchmarked, with a corrected pinning-diagnosis section for JEP 491
+
+
+ 25
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/virtual-threads-benchmark/scripts/check-trace-pinned-threads-removed.sh b/virtual-threads-benchmark/scripts/check-trace-pinned-threads-removed.sh
new file mode 100755
index 0000000..bf10075
--- /dev/null
+++ b/virtual-threads-benchmark/scripts/check-trace-pinned-threads-removed.sh
@@ -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"
diff --git a/virtual-threads-benchmark/scripts/run-all.sh b/virtual-threads-benchmark/scripts/run-all.sh
new file mode 100755
index 0000000..f8a1ae2
--- /dev/null
+++ b/virtual-threads-benchmark/scripts/run-all.sh
@@ -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/
diff --git a/virtual-threads-benchmark/scripts/run.sh b/virtual-threads-benchmark/scripts/run.sh
new file mode 100755
index 0000000..abdc50b
--- /dev/null
+++ b/virtual-threads-benchmark/scripts/run.sh
@@ -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}"
diff --git a/virtual-threads-benchmark/src/main/java/com/ankurm/vthreads/DemoController.java b/virtual-threads-benchmark/src/main/java/com/ankurm/vthreads/DemoController.java
new file mode 100644
index 0000000..93f11a9
--- /dev/null
+++ b/virtual-threads-benchmark/src/main/java/com/ankurm/vthreads/DemoController.java
@@ -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();
+ }
+}
diff --git a/virtual-threads-benchmark/src/main/java/com/ankurm/vthreads/PinningDemoService.java b/virtual-threads-benchmark/src/main/java/com/ankurm/vthreads/PinningDemoService.java
new file mode 100644
index 0000000..8bd2d26
--- /dev/null
+++ b/virtual-threads-benchmark/src/main/java/com/ankurm/vthreads/PinningDemoService.java
@@ -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);
+ }
+ }
+}
diff --git a/virtual-threads-benchmark/src/main/java/com/ankurm/vthreads/PinningTraceCheckMain.java b/virtual-threads-benchmark/src/main/java/com/ankurm/vthreads/PinningTraceCheckMain.java
new file mode 100644
index 0000000..01f9df0
--- /dev/null
+++ b/virtual-threads-benchmark/src/main/java/com/ankurm/vthreads/PinningTraceCheckMain.java
@@ -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.");
+ }
+}
diff --git a/virtual-threads-benchmark/src/main/java/com/ankurm/vthreads/ScopedValueDemo.java b/virtual-threads-benchmark/src/main/java/com/ankurm/vthreads/ScopedValueDemo.java
new file mode 100644
index 0000000..b5abf81
--- /dev/null
+++ b/virtual-threads-benchmark/src/main/java/com/ankurm/vthreads/ScopedValueDemo.java
@@ -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 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.
+ }
+}
diff --git a/virtual-threads-benchmark/src/main/java/com/ankurm/vthreads/VirtualThreadsBoot4DemoApplication.java b/virtual-threads-benchmark/src/main/java/com/ankurm/vthreads/VirtualThreadsBoot4DemoApplication.java
new file mode 100644
index 0000000..492c052
--- /dev/null
+++ b/virtual-threads-benchmark/src/main/java/com/ankurm/vthreads/VirtualThreadsBoot4DemoApplication.java
@@ -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);
+ }
+}
diff --git a/virtual-threads-benchmark/src/main/resources/application.yml b/virtual-threads-benchmark/src/main/resources/application.yml
new file mode 100644
index 0000000..278c769
--- /dev/null
+++ b/virtual-threads-benchmark/src/main/resources/application.yml
@@ -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
diff --git a/virtual-threads-benchmark/src/test/java/com/ankurm/vthreads/LoadBenchmarkTest.java b/virtual-threads-benchmark/src/test/java/com/ankurm/vthreads/LoadBenchmarkTest.java
new file mode 100644
index 0000000..a3c8629
--- /dev/null
+++ b/virtual-threads-benchmark/src/test/java/com/ankurm/vthreads/LoadBenchmarkTest.java
@@ -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 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 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 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) 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();
+ }
+ }
+}
diff --git a/virtual-threads-benchmark/src/test/java/com/ankurm/vthreads/PinningJep491Test.java b/virtual-threads-benchmark/src/test/java/com/ankurm/vthreads/PinningJep491Test.java
new file mode 100644
index 0000000..a8b97e1
--- /dev/null
+++ b/virtual-threads-benchmark/src/test/java/com/ankurm/vthreads/PinningJep491Test.java
@@ -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);
+ }
+}
diff --git a/virtual-threads-benchmark/src/test/java/com/ankurm/vthreads/ScopedValueTest.java b/virtual-threads-benchmark/src/test/java/com/ankurm/vthreads/ScopedValueTest.java
new file mode 100644
index 0000000..13c33f0
--- /dev/null
+++ b/virtual-threads-benchmark/src/test/java/com/ankurm/vthreads/ScopedValueTest.java
@@ -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");
+ }
+}
diff --git a/virtual-threads-benchmark/src/test/java/com/ankurm/vthreads/ThreadTypeTest.java b/virtual-threads-benchmark/src/test/java/com/ankurm/vthreads/ThreadTypeTest.java
new file mode 100644
index 0000000..2e4bb15
--- /dev/null
+++ b/virtual-threads-benchmark/src/test/java/com/ankurm/vthreads/ThreadTypeTest.java
@@ -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) 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();
+ }
+ }
+}
diff --git a/virtual-threads-benchmark/src/test/java/com/ankurm/vthreads/Transcript.java b/virtual-threads-benchmark/src/test/java/com/ankurm/vthreads/Transcript.java
new file mode 100644
index 0000000..8d4a8be
--- /dev/null
+++ b/virtual-threads-benchmark/src/test/java/com/ankurm/vthreads/Transcript.java
@@ -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);
+ }
+ }
+}