Add virtual-threads-benchmark: re-run Spring Boot 4.1 / JDK 25 benchmarks, JEP 491 pinning fixed

Companion module for the rewritten post 'Virtual Threads on Spring Boot 4.1: The Benchmarks,
Re-Run, and the Pinning Advice That Expired', retitled and re-benchmarked on Boot 4.1.1 /
JDK 25.0.4.1 (the original post was written against Boot 3.4 / JDK 21). Covers: I/O-bound and
CPU-bound throughput (platform vs virtual threads, including a JIT-warmup benchmarking bug this
build caught and fixed), JEP 491 proof that synchronized no longer pins a virtual thread's
carrier across a blocking call as of JDK 24 (obsoleting the old avoid-synchronized advice),
proof that -Djdk.tracePinnedThreads=full is inert on JDK 25, and JEP 506's finalized ScopedValue
API (JDK 25 GA, no --enable-preview, and a different shape than the old preview API). Kept as
its own module rather than a new top-level repository, alongside the existing async/ module,
which already has a stronger dual-JDK JEP 491 proof that this module's docs cross-link to
instead of duplicating.
This commit is contained in:
2026-09-18 08:52:59 +00:00
parent eaa8171966
commit f506b01389
28 changed files with 1085 additions and 0 deletions
@@ -0,0 +1,164 @@
package com.ankurm.vthreads;
import org.junit.jupiter.api.Test;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.server.context.WebServerInitializedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.context.ConfigurableApplicationContext;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Real concurrent-load comparison of platform threads (Boot's default embedded Tomcat pool)
* against virtual threads (spring.threads.virtual.enabled=true), against a real running
* embedded server, on the two endpoints in DemoController. This box is a 2 vCPU sandbox, not
* the 4-core/8GB machine the original 2025 post benchmarked on -- the absolute numbers below
* are honestly smaller and noisier than a real server's, but the SHAPE of the result (I/O-bound
* scales with virtual threads, CPU-bound does not) is the same physics either hardware runs on,
* and every number here comes from this run, not the old post's numbers copied forward.
*
* Output: docs/output/01-io-bound-benchmark.txt, docs/output/02-cpu-bound-benchmark.txt.
* See docs/02-benchmark-methodology.md.
*/
class LoadBenchmarkTest {
private record Result(int total, int success, long wallMs, double p50, double p99) {}
private Result fireConcurrent(String baseUrl, String path, int concurrency) throws Exception {
HttpClient client = HttpClient.newBuilder()
.executor(Executors.newVirtualThreadPerTaskExecutor())
.build();
List<Long> latencies = Collections.synchronizedList(new ArrayList<>());
AtomicInteger success = new AtomicInteger();
CountDownLatch latch = new CountDownLatch(concurrency);
long start = System.nanoTime();
for (int i = 0; i < concurrency; i++) {
Thread.ofVirtual().start(() -> {
long reqStart = System.nanoTime();
try {
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + path)).build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) {
success.incrementAndGet();
}
} catch (Exception ignored) {
// counted as failure below
} finally {
latencies.add((System.nanoTime() - reqStart) / 1_000_000);
latch.countDown();
}
});
}
latch.await(60, TimeUnit.SECONDS);
long wallMs = (System.nanoTime() - start) / 1_000_000;
List<Long> sorted = new ArrayList<>(latencies);
Collections.sort(sorted);
double p50 = sorted.isEmpty() ? 0 : sorted.get(sorted.size() / 2);
int p99Idx = sorted.isEmpty() ? 0 : Math.min(sorted.size() - 1, (int) (sorted.size() * 0.99));
double p99 = sorted.isEmpty() ? 0 : sorted.get(p99Idx);
return new Result(concurrency, success.get(), wallMs, p50, p99);
}
@Test
void ioBoundScenario() throws Exception {
int concurrency = 600; // > default Tomcat platform-thread pool of 200
Result platform = runAgainst(false, "/io", concurrency);
Result virtual = runAgainst(true, "/io", concurrency);
Transcript t = Transcript.start("01-io-bound-benchmark.txt",
"I/O-bound endpoint (/io, Thread.sleep(300)), concurrency=" + concurrency
+ ", 2 vCPU sandbox, Spring Boot 4.1.1 / JDK " + System.getProperty("java.version"));
t.line(String.format("platform threads : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
platform.total, platform.success, platform.wallMs, platform.p50, platform.p99));
t.line(String.format("virtual threads : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
virtual.total, virtual.success, virtual.wallMs, virtual.p50, virtual.p99));
t.blank();
t.line("default embedded Tomcat platform-thread pool = 200; with 600 concurrent 300ms-sleep");
t.line("requests, platform threads must queue in ~3 sequential batches, virtual threads do not.");
t.save();
// The real, checked claim: virtual threads finish this workload meaningfully faster
// wall-clock than the platform-thread pool, on THIS run, on THIS hardware.
assertThat(virtual.wallMs).isLessThan(platform.wallMs);
assertThat(virtual.success).isEqualTo(concurrency);
assertThat(platform.success).isEqualTo(concurrency);
}
@Test
void cpuBoundScenario() throws Exception {
int concurrency = 60; // well beyond 2 vCPUs -- forces real core-bound saturation for both models
Result platform = runAgainst(false, "/cpu", concurrency);
Result virtual = runAgainst(true, "/cpu", concurrency);
Transcript t = Transcript.start("02-cpu-bound-benchmark.txt",
"CPU-bound endpoint (/cpu, 20,000x SHA-256), concurrency=" + concurrency
+ ", 2 vCPU sandbox, Spring Boot 4.1.1 / JDK " + System.getProperty("java.version"));
t.line(String.format("platform threads : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
platform.total, platform.success, platform.wallMs, platform.p50, platform.p99));
t.line(String.format("virtual threads : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
virtual.total, virtual.success, virtual.wallMs, virtual.p50, virtual.p99));
t.blank();
t.line("a CPU-bound virtual thread never yields -- it stays mounted on its carrier for the full");
t.line("computation, so it competes for the same 2 physical cores a platform thread would. Expect");
t.line("these two wall times to be close, not virtual threads winning decisively as in the I/O case.");
t.save();
assertThat(platform.success).isEqualTo(concurrency);
assertThat(virtual.success).isEqualTo(concurrency);
// Not asserting virtual < platform here on purpose -- the whole point of this test is that
// CPU-bound work does NOT reliably favor either thread model. See docs/02.
}
private Result runAgainst(boolean virtualThreads, String path, int concurrency) throws Exception {
AtomicInteger capturedPort = new AtomicInteger(-1);
CountDownLatch portLatch = new CountDownLatch(1);
SpringApplicationBuilder builder = new SpringApplicationBuilder(VirtualThreadsBoot4DemoApplication.class)
.initializers(ctx -> ctx.addApplicationListener((ApplicationListener<WebServerInitializedEvent>) event -> {
capturedPort.set(event.getWebServer().getPort());
portLatch.countDown();
}));
// Passed as command-line-style args (highest property precedence) rather than
// builder.properties(...) (which binds to "defaultProperties" -- LOWEST precedence,
// so it was silently losing to the explicit false in application.yml. Real bug,
// caught because the test asserted the actual thread type instead of just that the
// app started.)
ConfigurableApplicationContext ctx = builder.run(
"--server.port=0",
"--spring.threads.virtual.enabled=" + virtualThreads,
"--spring.jmx.enabled=false");
try {
portLatch.await(10, TimeUnit.SECONDS);
String baseUrl = "http://localhost:" + capturedPort.get();
// Warm-up: JIT compiles hot methods (MessageDigest.digest, the servlet dispatch
// path) per-CLASS, not per-context, so within one JVM whichever scenario runs
// SECOND gets a free JIT head start that has nothing to do with thread model.
// First cut of this benchmark ran platform-threads first every time and
// attributed the resulting gap to virtual threads -- discovered by inspecting the
// CPU-bound numbers, where virtual threads had no right to win by 4x. Fire an
// untimed warm-up round through the SAME code paths before the real measurement.
fireConcurrent(baseUrl, path, Math.min(concurrency, 30));
return fireConcurrent(baseUrl, path, concurrency);
} finally {
ctx.close();
}
}
}
@@ -0,0 +1,64 @@
package com.ankurm.vthreads;
import org.junit.jupiter.api.Test;
import java.util.concurrent.CountDownLatch;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Proves JEP 491 (GA in JDK 24) empirically rather than citing it: N virtual threads
* each hold a DISTINCT monitor (via synchronized, on a lock object no other thread ever
* touches) across a blocking Thread.sleep. Before JDK 24 this pinned every one of them to
* a carrier thread for the full sleep duration, so wall time scaled with
* ceil(N / availableProcessors) * sleepMillis. On JDK 24+, it does not: wall time stays
* close to sleepMillis regardless of N or the carrier pool size, because the virtual
* thread unmounts from its carrier while sleeping even though it still holds the lock.
*
* Output: docs/output/03a-pinning-jep491-proof.txt. See docs/03-pinning-diagnosis.md.
*/
class PinningJep491Test {
@Test
void synchronizedNoLongerPinsAcrossBlockingSleep() throws InterruptedException {
int n = 60;
long sleepMillis = 250;
int carrierCount = Runtime.getRuntime().availableProcessors();
long wallTimeIfPinnedMs = ((n + carrierCount - 1) / carrierCount) * sleepMillis;
Transcript t = Transcript.start("03a-pinning-jep491-proof.txt",
"JEP 491 proof: synchronized held across Thread.sleep, JDK " + System.getProperty("java.version"));
t.line("availableProcessors (default virtual-thread carrier pool size) = " + carrierCount);
t.line("virtual threads = " + n + ", each holds a DISTINCT monitor for " + sleepMillis + "ms");
t.line("predicted wall time IF PINNED (pre-JDK-24 behaviour): ~" + wallTimeIfPinnedMs + "ms");
t.line("predicted wall time IF NOT PINNED (JDK 24+ behaviour): ~" + sleepMillis + "ms, independent of carrier count");
t.blank();
CountDownLatch latch = new CountDownLatch(n);
long start = System.nanoTime();
for (int i = 0; i < n; i++) {
Object myLock = new Object();
Thread.ofVirtual().start(() -> {
try {
PinningDemoService.doWorkHoldingDistinctMonitor(myLock, sleepMillis);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
latch.countDown();
}
});
}
latch.await();
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
t.line("actual wall time: " + elapsedMs + "ms");
t.line("verdict: " + (elapsedMs < wallTimeIfPinnedMs / 2
? "NOT PINNED -- matches JEP 491's documented JDK 24+ behaviour"
: "PINNED -- matches pre-JDK-24 behaviour"));
t.save();
// The real assertion: this build fails the day this stops being true on whatever
// JDK runs it, e.g. if run on JDK < 24.
assertThat(elapsedMs).isLessThan(wallTimeIfPinnedMs / 2);
}
}
@@ -0,0 +1,27 @@
package com.ankurm.vthreads;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Confirms the finalized JDK 25 ScopedValue API (JEP 506) compiles and runs with no
* --enable-preview flag, and that ScopedValue.where(...).call(...) actually threads the value
* through to a method called from inside the scope. Output:
* docs/output/03c-scoped-value-finalized.txt.
*/
class ScopedValueTest {
@Test
void whereRunThreadsContextThroughToCalledMethod() {
Transcript t = Transcript.start("03c-scoped-value-finalized.txt",
"ScopedValue (JEP 506, finalized JDK 25), JDK " + System.getProperty("java.version"));
String result = ScopedValueDemo.runWithContext("req-42");
t.line("ScopedValue.where(CTX, ctx).call(ScopedValueDemo::processRequest) -> " + result);
t.line("no --enable-preview flag used on this run");
t.save();
assertThat(result).isEqualTo("handled req-42");
}
}
@@ -0,0 +1,65 @@
package com.ankurm.vthreads;
import org.junit.jupiter.api.Test;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.server.context.WebServerInitializedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.web.client.RestClient;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Confirms spring.threads.virtual.enabled actually changes the request-handling thread type,
* against a real running embedded server (not a description). Output:
* docs/output/00-thread-type-confirmation.txt.
*/
class ThreadTypeTest {
@Test
void flagTogglesRequestThreadType() throws Exception {
String withoutFlag = hit(false);
String withFlag = hit(true);
Transcript t = Transcript.start("00-thread-type-confirmation.txt",
"GET /thread-info with spring.threads.virtual.enabled=false vs true");
t.line("enabled=false -> " + withoutFlag);
t.line("enabled=true -> " + withFlag);
t.save();
assertThat(withoutFlag).contains("Virtual: false");
assertThat(withFlag).contains("Virtual: true");
}
private String hit(boolean virtualThreads) throws Exception {
AtomicInteger capturedPort = new AtomicInteger(-1);
CountDownLatch portLatch = new CountDownLatch(1);
SpringApplicationBuilder builder = new SpringApplicationBuilder(VirtualThreadsBoot4DemoApplication.class)
.initializers(ctx -> ctx.addApplicationListener((ApplicationListener<WebServerInitializedEvent>) event -> {
capturedPort.set(event.getWebServer().getPort());
portLatch.countDown();
}));
// Passed as command-line-style args (highest property precedence) rather than
// builder.properties(...) (which binds to "defaultProperties" -- LOWEST precedence,
// so it was silently losing to the explicit false in application.yml. Real bug,
// caught because the test asserted the actual thread type instead of just that the
// app started.)
ConfigurableApplicationContext ctx = builder.run(
"--server.port=0",
"--spring.threads.virtual.enabled=" + virtualThreads,
"--spring.jmx.enabled=false");
try {
portLatch.await(10, TimeUnit.SECONDS);
RestClient rest = RestClient.create("http://localhost:" + capturedPort.get());
return rest.get().uri("/thread-info").retrieve().body(String.class);
} finally {
ctx.close();
}
}
}
@@ -0,0 +1,50 @@
package com.ankurm.vthreads;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
/**
* Writes docs/output/NN-*.txt while a test runs, so every number quoted in the blog post
* is backed by a file produced by an assertion that would fail the build if it stopped
* being true. Never hand-edit files under docs/output/ — regenerate with scripts/run-all.sh.
*/
public final class Transcript {
private final StringBuilder buf = new StringBuilder();
private final Path outFile;
private Transcript(String fileName) {
this.outFile = Paths.get("docs/output", fileName);
}
public static Transcript start(String fileName, String header) {
Transcript t = new Transcript(fileName);
t.line(header);
t.line("=".repeat(header.length()));
return t;
}
public Transcript line(String s) {
buf.append(s).append('\n');
return this;
}
public Transcript blank() {
buf.append('\n');
return this;
}
public void save() {
try {
Files.createDirectories(outFile.getParent());
Files.writeString(outFile, buf.toString(), StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}