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