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); + } + } +}