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:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user