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