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