Files
asmhatre f506b01389 Add virtual-threads-benchmark: re-run Spring Boot 4.1 / JDK 25 benchmarks, JEP 491 pinning fixed
Companion module for the rewritten post 'Virtual Threads on Spring Boot 4.1: The Benchmarks,
Re-Run, and the Pinning Advice That Expired', retitled and re-benchmarked on Boot 4.1.1 /
JDK 25.0.4.1 (the original post was written against Boot 3.4 / JDK 21). Covers: I/O-bound and
CPU-bound throughput (platform vs virtual threads, including a JIT-warmup benchmarking bug this
build caught and fixed), JEP 491 proof that synchronized no longer pins a virtual thread's
carrier across a blocking call as of JDK 24 (obsoleting the old avoid-synchronized advice),
proof that -Djdk.tracePinnedThreads=full is inert on JDK 25, and JEP 506's finalized ScopedValue
API (JDK 25 GA, no --enable-preview, and a different shape than the old preview API). Kept as
its own module rather than a new top-level repository, alongside the existing async/ module,
which already has a stronger dual-JDK JEP 491 proof that this module's docs cross-link to
instead of duplicating.
2026-09-18 08:52:59 +00:00

4.5 KiB

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, 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

./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 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 ThreadTypeTest 00
I/O-bound throughput: platform vs virtual threads, 600 concurrent same LoadBenchmarkTest 01
CPU-bound throughput: platform vs virtual threads, 60 concurrent (plus the JIT-warmup benchmarking bug this repo caught and fixed) same same 02
JEP 491 proof: synchronized no longer pins across a blocking sleep on JDK 24+ PinningDemoService PinningJep491Test 03a
-Djdk.tracePinnedThreads=full is inert on JDK 25 PinningTraceCheckMain check-trace-pinned-threads-removed.sh 03b

Documentation chapters

  1. Enabling virtual threads on Spring Boot 4.1 -- the one property, what it actually flips, and a SpringApplicationBuilder property-precedence trap this repo's own benchmark hit
  2. Benchmark methodology and results -- 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 -- 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

A note on this module's relationship to ../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 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.