Compare commits

..
2 Commits
Author SHA1 Message Date
asmhatre 6af6e4a3f1 Add the scheduling module
Three replicas of one application against one PostgreSQL database, proving
duplicate @Scheduled execution and then removing it with ShedLock: 24
executions where 8 were due, then 7 for 7 ticks.

Also measured: @SchedulerLock without @EnableSchedulerLock does nothing and
warns about nothing; spring.task.scheduling.pool.size=1 does not starve a
fixedRate job but delays it and fires 35 of 40 executions in a burst; and a
node whose clock is 40 seconds fast takes a live lock unless the provider
uses usingDbTime().
2026-09-01 23:28:06 +05:30
asmhatre 243cccd4ca Add the async module
Every @Async behaviour that surprises people, asserted by a test and captured
to docs/output/: the self-invocation trap, what CGLIB cannot override, the
IllegalArgumentException a plain return type throws, the unbounded queue that
makes max-size decoration, spring.task.execution.propagate-context (new in
Boot 4.1.0), the two Executor beans that leave @Async on an unpooled
SimpleAsyncTaskExecutor, and JEP 491 measured on JDK 21 against JDK 25.
2026-09-01 23:27:47 +05:30
78 changed files with 1 additions and 2961 deletions
-10
View File
@@ -10,9 +10,6 @@ by that module's `scripts/run-all.sh`, never typed by hand.
|---|---|---| |---|---|---|
| [`async/`](async/README.md) | [@Async in Spring Boot 4: Executors, Virtual Threads and the Self-Invocation Trap](https://ankurm.com/spring-boot-4-async-executors-virtual-threads/) | Which thread a method actually ran on, in every case where the answer is not the one you expect | | [`async/`](async/README.md) | [@Async in Spring Boot 4: Executors, Virtual Threads and the Self-Invocation Trap](https://ankurm.com/spring-boot-4-async-executors-virtual-threads/) | Which thread a method actually ran on, in every case where the answer is not the one you expect |
| [`scheduling/`](scheduling/README.md) | [@Scheduled, ShedLock and Distributed Cron: Scheduling That Survives Three Replicas](https://ankurm.com/spring-scheduled-shedlock-distributed-cron/) | Three replicas against one database running the same job three times, then one row and one conditional UPDATE fixing it | | [`scheduling/`](scheduling/README.md) | [@Scheduled, ShedLock and Distributed Cron: Scheduling That Survives Three Replicas](https://ankurm.com/spring-scheduled-shedlock-distributed-cron/) | Three replicas against one database running the same job three times, then one row and one conditional UPDATE fixing it |
| [`awaitility/`](awaitility/README.md) | Testing Asynchronous Code with Awaitility (@Async, Kafka Listeners, Schedulers) | Replacing `Thread.sleep` with `await()` across a void `@Async` method, an `@KafkaListener`, and a `@Scheduled` job — plus the real pom.xml trap where Boot 4.1.1 split Kafka's autoconfiguration out of `spring-boot-autoconfigure` |
| [`virtual-threads-benchmark/`](virtual-threads-benchmark/README.md) | [Virtual Threads on Spring Boot 4.1: The Benchmarks, Re-Run, and the Pinning Advice That Expired](https://ankurm.com/leveraging-virtual-threads-in-spring-boot-3-4-building-high-throughput-services/) | Platform threads vs virtual threads, re-benchmarked on Boot 4.1.1 / JDK 25, plus JEP 491's fix to `synchronized` pinning proven against a real JDK |
| [`virtual-threads-benchmark-webflux/`](virtual-threads-benchmark-webflux/README.md) | [Virtual Threads vs Reactive (WebFlux) vs Platform Threads: Benchmarks and a Decision Framework](https://ankurm.com/virtual-threads-vs-webflux-vs-platform-threads-spring-boot-benchmarks/) | The WebFlux leg of the three-way comparison, plus the event-loop-starvation failure mode an isolated CPU benchmark can't show |
## Common ground ## Common ground
@@ -34,13 +31,6 @@ The `scheduling` module also needs a database. `scheduling/scripts/postgres.sh`
throwaway PostgreSQL 14 into `target/` with no Docker and no root, which is how its transcripts throwaway PostgreSQL 14 into `target/` with no Docker and no root, which is how its transcripts
were produced; `docker-compose.yml` is there for anyone who would rather use Docker. were produced; `docker-compose.yml` is there for anyone who would rather use Docker.
`virtual-threads-benchmark` and `virtual-threads-benchmark-webflux` are a similar pair: the
first re-benchmarks platform threads against virtual threads for one post, the second adds the
WebFlux leg for a different, three-way-comparison post, and reuses the first module's
committed transcripts rather than re-measuring the same thing twice. Both use the same
client-side load generator (`java.net.http.HttpClient` on a virtual-thread executor, client
role only) so all three threading models in the three-way post are measured the same way.
## Licence ## Licence
MIT — see [LICENSE](LICENSE). MIT — see [LICENSE](LICENSE).
+1 -1
View File
@@ -52,6 +52,6 @@ against `ContextRegistry`. Two tests differ only in the property:
`SecurityContext` propagation is a related but separate mechanism, with its own set of ways to `SecurityContext` propagation is a related but separate mechanism, with its own set of ways to
get it wrong; that is covered in get it wrong; that is covered in
[Spring Security Context Propagation: The Complete Guide](https://ankurm.com/spring-security-context-propagation-complete-guide/). [Virtual Threads and SecurityContext Propagation](https://ankurm.com/spring-security-virtual-threads-context-propagation/).
next: [Which executor runs it](07-which-executor-runs-it.md) next: [Which executor runs it](07-which-executor-runs-it.md)
-84
View File
@@ -1,84 +0,0 @@
# awaitility
Companion code for the Awaitility article on [ankurm.com](https://ankurm.com). All intermediate
and reference-grade depth lives in the post itself (accordions / "going deeper" paragraphs), not
in a `docs/NN-topic.md` chapter folder — the one exception, as in the rest of this repository, is
`docs/output/`, which holds real captured transcripts and nothing else.
## What this is
Six real, runnable demonstrations of the same idea: a fixed `Thread.sleep(...)` in a test is a
guess about timing the test does not control, and `Awaitility.await()` replaces the guess with a
bounded poll loop. Each demonstration targets a different source of asynchrony:
| Test | What it waits for |
|---|---|
| `AwaitAsyncConfirmationTest` | A `void` `@Async` method with no `Future` to block on |
| `DefaultTimingTest` | Nothing — it proves Awaitility's own defaults (10s timeout, 100ms poll interval) against the real jar |
| `KafkaListenerAwaitTest` | An `@KafkaListener` consuming a record a `KafkaTemplate` just sent, against a real in-process `@EmbeddedKafka` broker |
| `ScheduledJobAwaitTest` | Three more executions of an already-running `@Scheduled(fixedRate = 150)` job |
| `UncaughtExceptionHandlerSwapTest` | Nothing — it proves `await()` temporarily installs its own `Thread.setDefaultUncaughtExceptionHandler` and restores the original afterward |
| `IgnoreExceptionsTest` | A resource that throws for its first 300ms, using `ignoreExceptionsInstanceOf(...)` to treat that as "not yet", not a failure |
Two more transcripts in `docs/output/` are real failures from tests that are *not* in the suite
above: `01-sleep-guesses-wrong.txt` (a fixed `Thread.sleep(100)` against a 220ms operation) and
`08-exception-propagates-immediately.txt` (the same resource as `IgnoreExceptionsTest`, minus
`ignoreExceptionsInstanceOf(...)`). Both were real `mvn test` runs, captured once, then the
failing test class was deleted — the mistake is preserved as a transcript, not as a permanently
red test.
## The pom.xml trap this module exists to document
The first version of this module's `pom.xml` depended on `org.springframework.kafka:spring-kafka`
directly, the way every pre-Boot-4.1 tutorial does. It compiled. Every `@SpringBootTest` using
Kafka then failed with an empty `ConcurrentLinkedQueue` and no error at context startup, because
in Boot 4.1.1 the Kafka autoconfiguration classes (`KafkaTemplate`, `ConsumerFactory`, the
`KafkaListenerEndpointRegistry` that `@KafkaListener` needs) moved out of the monolithic
`spring-boot-autoconfigure` jar into their own module, `org.springframework.boot:spring-boot-kafka`
— pulled in by the new `org.springframework.boot:spring-boot-starter-kafka`, not by `spring-kafka`
alone. See `pom.xml`'s own comments and the post for the full diagnosis.
## Versions
Read from `spring-boot-dependencies-4.1.1.pom` and `spring-boot-starter-test-4.1.1.pom` on Maven
Central, not from release notes: **JDK 25** (Temurin 25.0.4.1+1), **Spring Boot 4.1.1**,
**Spring Kafka 4.1.1**, **Awaitility 4.3.0** (already on the classpath via
`spring-boot-starter-test` — no explicit `<dependency>` for it anywhere in `pom.xml`).
## Quickstart
```
mvn test
```
11 tests, 0 failures. `KafkaListenerAwaitTest` starts a real in-process Kafka broker
(`@EmbeddedKafka`) and takes a few seconds; `DefaultTimingTest` deliberately waits out Awaitility's
real 10-second default timeout once, so the suite as a whole takes about 30 seconds.
## Captured output
| File | What it's from |
|---|---|
| `00-full-test-run.txt` | The full `mvn test` run, 11/11 green |
| `01-sleep-guesses-wrong.txt` | Real failure: a guessed `Thread.sleep(100)` against a 220ms operation (test since removed) |
| `02-await-finds-it.txt` | The fixed version: `await().untilAsserted(...)` against the same operation |
| `03-default-timeout-is-ten-seconds.txt` | `await().until(() -> false)` timing out at ~10,000ms with no override |
| `04-default-poll-interval-is-100ms.txt` | Real poll timestamps, ~100ms apart, with no override |
| `05-kafka-listener-await.txt` | `await()` for an `@KafkaListener` to consume a record just sent |
| `06-scheduled-job-await.txt` | `await()` for 3 more executions of an already-running `@Scheduled` job |
| `07-uncaught-exception-handler-swap.txt` | Proof that `await()` swaps and restores the JVM's default uncaught-exception handler |
| `08-exception-propagates-immediately.txt` | Real failure: an exception from the polled condition, with no `ignoreExceptionsInstanceOf(...)`, failing on the first poll (test since removed) |
| `09-ignore-exceptions-waits-it-out.txt` | The fixed version: the same resource, with `ignoreExceptionsInstanceOf(...)` |
| `10-missing-kafka-starter-failure.txt` | Real failure: the Kafka test with `spring-kafka` as a direct dependency instead of `spring-boot-starter-kafka` (pom.xml since fixed) |
| `11-pom-diagnosis.txt` | The real `curl`/`grep` transcript that found the Boot 4.1 Kafka module split against Maven Central's own POMs |
## What's sourced from documentation, not run here
Awaitility's own `@since` Javadoc tags, Spring Boot's own migration notes for the Kafka module
split, and the general shape of `@KafkaListener`/`@EmbeddedKafka` wiring are cited from the real
jars and POMs on Maven Central (see the post for exact artifact coordinates), not re-derived from
scratch in this module.
## Licence
MIT — see [LICENSE](../LICENSE).
@@ -1,14 +0,0 @@
[INFO] Running com.ankurm.awaitility.KafkaListenerAwaitTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 8.012 s -- in com.ankurm.awaitility.KafkaListenerAwaitTest
[INFO] Running com.ankurm.awaitility.ScheduledJobAwaitTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.841 s -- in com.ankurm.awaitility.ScheduledJobAwaitTest
[INFO] Running com.ankurm.awaitility.UncaughtExceptionHandlerSwapTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.115 s -- in com.ankurm.awaitility.UncaughtExceptionHandlerSwapTest
[INFO] Running com.ankurm.awaitility.AwaitAsyncConfirmationTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.654 s -- in com.ankurm.awaitility.AwaitAsyncConfirmationTest
[INFO] Running com.ankurm.awaitility.IgnoreExceptionsTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.636 s -- in com.ankurm.awaitility.IgnoreExceptionsTest
[INFO] Running com.ankurm.awaitility.DefaultTimingTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 10.70 s -- in com.ankurm.awaitility.DefaultTimingTest
[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
@@ -1,9 +0,0 @@
== Thread.sleep(100) against a confirmation that actually takes 220ms (real failure, test since removed) ==
$ mvn -B -Dtest=SleepGuessesWrongTest test
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 3.568 s <<< FAILURE! -- in com.ankurm.awaitility.SleepGuessesWrongTest
com.ankurm.awaitility.SleepGuessesWrongTest.confirmationArrivesWithin100ms -- Time elapsed: 1.084 s <<< FAILURE!
java.lang.AssertionError:
Expecting actual not to be null
at com.ankurm.awaitility.SleepGuessesWrongTest.confirmationArrivesWithin100ms(SleepGuessesWrongTest.java:27)
@@ -1,6 +0,0 @@
== await().untilAsserted() against a 220ms-delayed void @Async method ==
mailbox.confirmationFor("ORD-CAPTURE") immediately before sendConfirmation(): null
await().atMost(2s).untilAsserted(...) returned after approximately: 307ms
mailbox.confirmationFor("ORD-CAPTURE") once await() returns: order ORD-CAPTURE confirmed
@@ -1,3 +0,0 @@
== await().until(() -> false) with no atMost(...) override ==
elapsed before ConditionTimeoutException: 10057ms (expected: ~10,000ms)
@@ -1,6 +0,0 @@
== await().atMost(550ms).until(...) with no pollInterval(...) override ==
poll timestamps (ms since start): [107, 207, 308, 409, 509]
gaps between consecutive polls (ms): [100, 101, 101, 100]
average gap: 100.5ms (expected: ~100ms)
@@ -1,6 +0,0 @@
== await() for an @KafkaListener to consume a record a KafkaTemplate just sent, against an @EmbeddedKafka broker ==
listener.received() immediately after send() returned: (not checked -- that is the point)
listener.received() once await() returned: [order-77-created]
approximate elapsed time: 378ms
@@ -1,7 +0,0 @@
== await() for 3 more @Scheduled(fixedRate = 150) executions, regardless of how long the job had already been running ==
runCount() when the test started: 1
target (baseline + 3): 4
runCount() once await() returned: 4
approximate elapsed time: 510ms
@@ -1,6 +0,0 @@
== Thread.getDefaultUncaughtExceptionHandler() before, during and after one await() call ==
handler during await() is the one this test installed: false (expected: false)
handler during await() is Awaitility's own (class org.awaitility.core.CallableCondition$1)
handler after await() is the one this test installed: true (expected: true)
@@ -1,11 +0,0 @@
== await().untilAsserted(...) with no ignoreExceptionsInstanceOf(...), against a resource that throws for its first 300ms (real failure, test since removed) ==
$ mvn -B -Dtest=ExceptionPropagatesImmediatelyTest test
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 2.940 s <<< FAILURE! -- in com.ankurm.awaitility.ExceptionPropagatesImmediatelyTest
com.ankurm.awaitility.ExceptionPropagatesImmediatelyTest.failsOnTheFirstPollInsteadOfWaiting -- Time elapsed: 0.946 s <<< ERROR!
java.lang.IllegalStateException: resource is still warming up
at com.ankurm.awaitility.FlakyStartupResource.value(FlakyStartupResource.java:26)
at com.ankurm.awaitility.ExceptionPropagatesImmediatelyTest.lambda$failsOnTheFirstPollInsteadOfWaiting$0(ExceptionPropagatesImmediatelyTest.java:28)
Note "Errors: 1", not a ConditionTimeoutException after the full atMost(1s) window: the exception
from the very first poll propagated straight out instead of being retried.
@@ -1,3 +0,0 @@
== await().ignoreExceptionsInstanceOf(IllegalStateException.class) against a resource that throws for its first 300ms ==
approximate elapsed time before resource.value() finally returned "ready": 302ms (expected: a little over 300ms)
@@ -1,23 +0,0 @@
== Real failure with org.springframework.kafka:spring-kafka as a direct dependency instead of org.springframework.boot:spring-boot-starter-kafka (pom.xml since fixed) ==
$ mvn -B test
[ERROR] Errors:
[ERROR] KafkaListenerAwaitTest.messageSentIsEventuallyConsumed:49 ? ConditionTimeout Assertion condition defined as a Lambda expression in com.ankurm.awaitility.KafkaListenerAwaitTest
Expecting ConcurrentLinkedQueue:
[]
to contain:
["order-42-created"]
but could not find the following element(s):
["order-42-created"]
within 5 seconds.
[INFO]
[ERROR] Tests run: 11, Failures: 0, Errors: 2, Skipped: 0
No exception at context startup, and the application context came up without a single warning.
Autowiring KafkaListenerEndpointRegistry directly in a scratch test and calling
getListenerContainers() on it confirmed the real cause: the bean did not exist. Neither did
KafkaTemplate, ConsumerFactory, or kafkaListenerContainerFactory. Grepping the full log for the
Spring Kafka logger prefix also confirmed it:
$ grep -c "o\.s\.k\." full-test-run.log
0
@@ -1,38 +0,0 @@
== Confirming the Boot 4.1 Kafka module split directly against Maven Central's real POMs ==
$ curl -sS https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-dependencies/4.1.1/spring-boot-dependencies-4.1.1.pom -o spring-boot-dependencies-4.1.1.pom
$ grep -B3 '<artifactId>spring-boot-kafka</artifactId>' spring-boot-dependencies-4.1.1.pom
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-kafka</artifactId>
$ curl -sS https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-starter-kafka/4.1.1/spring-boot-starter-kafka-4.1.1.pom | grep -A2 '<artifactId>'
<artifactId>spring-boot-starter-kafka</artifactId>
<version>4.1.1</version>
<name>spring-boot-starter-kafka</name>
--
<artifactId>spring-boot-starter</artifactId>
<version>4.1.1</version>
<scope>compile</scope>
--
<artifactId>spring-boot-kafka</artifactId>
<version>4.1.1</version>
<scope>compile</scope>
$ curl -sS https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-kafka/4.1.1/spring-boot-kafka-4.1.1.pom | grep -A2 '<artifactId>'
<artifactId>spring-boot-kafka</artifactId>
<version>4.1.1</version>
<name>spring-boot-kafka</name>
--
<artifactId>spring-boot</artifactId>
<version>4.1.1</version>
<scope>compile</scope>
--
<artifactId>spring-boot-transaction</artifactId>
<version>4.1.1</version>
<scope>compile</scope>
--
<artifactId>spring-kafka</artifactId>
<version>4.1.1</version>
<scope>compile</scope>
-83
View File
@@ -1,83 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- Boot 4.1.1 manages Spring Framework 7.0.9, Spring Kafka 4.1.1 and Awaitility 4.3.0.
Versions were read from spring-boot-dependencies-4.1.1.pom and
spring-boot-starter-test-4.1.1.pom, not from release notes. -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>awaitility</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<properties>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- NOT org.springframework.kafka:spring-kafka directly, that was Boot 3.x's shape.
In Boot 4.1.1 the Kafka autoconfiguration classes (KafkaTemplate, ConsumerFactory,
the KafkaListenerEndpointRegistry that @KafkaListener needs) were extracted out of the
monolithic spring-boot-autoconfigure jar into their own module, org.springframework.boot
:spring-boot-kafka. Depending on spring-kafka alone now gets you the annotations and
KafkaTemplate class with none of the autoconfiguration wiring: every @KafkaListener bean
is created but no container ever starts, silently, with no error at context startup.
This starter is what actually pulls spring-boot-kafka (confirmed via its real POM on
Maven Central) in on top of spring-kafka itself. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-kafka</artifactId>
</dependency>
<!-- No explicit <awaitility> dependency anywhere in this file. spring-boot-starter-test
4.1.1 already declares org.awaitility:awaitility:4.3.0 as a compile-scope dependency
of its own, confirmed against the real POM on Maven Central, not assumed. Every
project using this starter already has Awaitility on its test classpath today. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- The test-scope sibling of spring-boot-starter-kafka above: pulls spring-kafka-test
(the @EmbeddedKafka JUnit 5 extension and KafkaTestUtils) on the same Boot-4.1.1-managed
version as everything else here. @EmbeddedKafka starts a real, in-process Kafka broker
for the duration of a test class, no Docker, no Testcontainers, no network port opened
outside this JVM, which is also why it is the only honest way to demonstrate Kafka
listener testing in a sandbox with no Docker daemon. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-kafka-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version>
</plugin>
</plugins>
</build>
</project>
-13
View File
@@ -1,13 +0,0 @@
#!/usr/bin/env bash
# Regenerates every file under docs/output/. Nothing in this module's documentation, or in the
# article it accompanies, is typed by hand.
#
# scripts/run-all.sh
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p docs/output
mvn -B test > /tmp/awaitility-run.log 2>&1 || { cat /tmp/awaitility-run.log; exit 1; }
grep -E 'Running |Tests run:|BUILD ' /tmp/awaitility-run.log > docs/output/00-full-test-run.txt
echo "wrote docs/output/00-full-test-run.txt"
echo "the rest of docs/output/*.txt is written by the tests themselves (see Capture.java)"
@@ -1,37 +0,0 @@
package com.ankurm.awaitility;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
/**
* A deliberately {@code void} {@code @Async} method: this is the case from
* docs/01-what-async-actually-does.md (in the sibling {@code async} module) where there is no
* {@code Future} the caller can block on, by design &mdash; the method is meant to be
* fire-and-forget. Fire-and-forget is also the exact shape of code that pushes people toward
* {@code Thread.sleep} in tests, because there is nothing else to wait on.
*/
@Service
public class AsyncGreetingService {
private final Mailbox mailbox;
public AsyncGreetingService(Mailbox mailbox) {
this.mailbox = mailbox;
}
@Async
public void sendConfirmation(String orderId) {
sleepFor(220);
mailbox.record(orderId, "order " + orderId + " confirmed");
}
private static void sleepFor(long millis) {
try {
Thread.sleep(millis);
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException(ex);
}
}
}
@@ -1,25 +0,0 @@
package com.ankurm.awaitility;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* Entry point for the Awaitility demonstrations.
*
* <p>{@code @EnableAsync} and {@code @EnableScheduling} turn on the two Spring mechanisms this
* module tests against: a fire-and-forget {@code @Async} method with no {@code Future} to block
* on, and a {@code @Scheduled} job running on its own thread. Kafka needs no equivalent
* annotation here &mdash; {@code spring-kafka} on the classpath is enough for Spring Boot's
* {@code KafkaAutoConfiguration} to wire a listener container factory.
*/
@SpringBootApplication
@EnableAsync
@EnableScheduling
public class AwaitilityDemoApplication {
public static void main(String[] args) {
SpringApplication.run(AwaitilityDemoApplication.class, args);
}
}
@@ -1,30 +0,0 @@
package com.ankurm.awaitility;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.stereotype.Component;
/**
* Stands in for anything that is genuinely not ready yet right after it starts warming up
* &mdash; a connection pool, a cache still loading, a client still completing its first
* handshake &mdash; and throws rather than returning a sentinel while that is true. Call
* {@link #startWarmingUp()} to begin a 300ms window during which {@link #value()} throws
* {@link IllegalStateException}; after that it returns normally.
*/
@Component
public class FlakyStartupResource {
private final AtomicReference<Instant> readyAt = new AtomicReference<>(Instant.EPOCH);
public void startWarmingUp() {
readyAt.set(Instant.now().plus(Duration.ofMillis(300)));
}
public String value() {
if (Instant.now().isBefore(readyAt.get())) {
throw new IllegalStateException("resource is still warming up");
}
return "ready";
}
}
@@ -1,31 +0,0 @@
package com.ankurm.awaitility;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import org.springframework.stereotype.Component;
/**
* Stands in for "the side effect of an asynchronous operation" &mdash; a row written by a
* background thread that a test (or another part of the application) has no handle to wait on
* directly. {@link AsyncGreetingService#sendConfirmation(String)} writes here; nothing returns a
* {@code Future} for it to block on, which is the whole reason the test for it needs Awaitility
* rather than a blocking {@code get()}.
*/
@Component
public class Mailbox {
private final ConcurrentMap<String, String> sent = new ConcurrentHashMap<>();
void record(String orderId, String message) {
sent.put(orderId, message);
}
/** Returns {@code null} if nothing has been recorded for this order yet. */
public String confirmationFor(String orderId) {
return sent.get(orderId);
}
public int size() {
return sent.size();
}
}
@@ -1,34 +0,0 @@
package com.ankurm.awaitility;
import java.util.concurrent.ConcurrentLinkedQueue;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
/**
* Consumes from the {@code orders} topic on its own listener container thread. A test that sends
* a record with {@code KafkaTemplate} and then immediately inspects {@link #received()} is racing
* the consumer's poll loop &mdash; there is no return value, no {@code Future}, and no fixed
* delay that is both fast enough to keep the test quick and slow enough to never flake under CI
* load. See docs/output/05-kafka-listener-await.txt for why this module's own test does not use
* {@code Thread.sleep} either.
*
* <p>Gated behind {@code app.kafka-demo.enabled} (off by default, turned on only by the one test
* that needs it) so every other test's {@code @SpringBootTest} context does not also try to open
* a consumer against a broker that, for them, does not exist.
*/
@Component
@ConditionalOnProperty(name = "app.kafka-demo.enabled", havingValue = "true")
public class OrderEventListener {
private final ConcurrentLinkedQueue<String> received = new ConcurrentLinkedQueue<>();
@KafkaListener(topics = "orders", groupId = "awaitility-demo")
public void onOrderEvent(String payload) {
received.add(payload);
}
public ConcurrentLinkedQueue<String> received() {
return received;
}
}
@@ -1,26 +0,0 @@
package com.ankurm.awaitility;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* A trivial {@code @Scheduled} job: it exists only so a test has an asynchronous, externally
* triggered counter to wait on instead of sleeping for a guessed multiple of the fixed rate. See
* the sibling {@code scheduling} module for what happens to a job like this across three
* replicas; this one runs standalone and is not locked.
*/
@Component
public class ReportJob {
private final AtomicInteger runs = new AtomicInteger();
@Scheduled(fixedRate = 150)
public void run() {
runs.incrementAndGet();
}
public int runCount() {
return runs.get();
}
}
@@ -1,10 +0,0 @@
spring:
application:
name: awaitility-demo
# No spring.kafka.bootstrap-servers here. src/test/resources/application-test.yaml points it at
# ${spring.embedded.kafka.brokers}, the system property EmbeddedKafkaBroker sets for the
# duration of each test that carries @EmbeddedKafka.
app:
kafka-demo:
enabled: false # flipped on with a single @TestPropertySource, only by the Kafka test
@@ -1,58 +0,0 @@
package com.ankurm.awaitility;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
/**
* {@link AsyncGreetingService#sendConfirmation(String)} is {@code void}: there is no
* {@code Future} to call {@code get()} on, so the only way to find out when the background
* thread has written to {@link Mailbox} is to look &mdash; repeatedly, until it's there.
*
* <p>docs/output/01-sleep-guesses-wrong.txt is the real failure from the version of this test
* that guessed a fixed delay instead: {@code Thread.sleep(100)} against a confirmation that
* actually takes 220ms. It failed on every run, not just an unlucky one, which is the more
* common shape of a bad guess than the intermittent kind "flaky" usually implies.
*/
@SpringBootTest
class AwaitAsyncConfirmationTest {
@Autowired
private AsyncGreetingService service;
@Autowired
private Mailbox mailbox;
@Test
void confirmationArrivesEventually() {
service.sendConfirmation("ORD-1");
await().atMost(Duration.ofSeconds(2))
.untilAsserted(() -> assertThat(mailbox.confirmationFor("ORD-1")).isNotNull());
assertThat(mailbox.confirmationFor("ORD-1")).isEqualTo("order ORD-1 confirmed");
}
@Test
void capture() {
String before = mailbox.confirmationFor("ORD-CAPTURE");
service.sendConfirmation("ORD-CAPTURE");
long start = System.nanoTime();
await().atMost(Duration.ofSeconds(2))
.untilAsserted(() -> assertThat(mailbox.confirmationFor("ORD-CAPTURE")).isNotNull());
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
Capture.write("02-await-finds-it.txt",
"await().untilAsserted() against a 220ms-delayed void @Async method",
"""
mailbox.confirmationFor("ORD-CAPTURE") immediately before sendConfirmation(): %s
await().atMost(2s).untilAsserted(...) returned after approximately: %dms
mailbox.confirmationFor("ORD-CAPTURE") once await() returns: %s
""".formatted(before, elapsedMs, mailbox.confirmationFor("ORD-CAPTURE")));
}
}
@@ -1,23 +0,0 @@
package com.ankurm.awaitility;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
/** Writes a transcript under docs/output/ so every number in the article has a file behind it. */
final class Capture {
private Capture() {
}
static void write(String fileName, String heading, String body) {
Path dir = Path.of(System.getProperty("user.dir"), "docs", "output");
try {
Files.createDirectories(dir);
Files.writeString(dir.resolve(fileName), "== " + heading + " ==\n\n" + body + "\n");
}
catch (IOException ex) {
throw new IllegalStateException("could not write " + fileName, ex);
}
}
}
@@ -1,73 +0,0 @@
package com.ankurm.awaitility;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
/**
* Proves two numbers from the real jar instead of quoting them from the reference guide:
* {@code org.awaitility.Awaitility}'s {@code defaultWaitConstraint} is
* {@code AtMostWaitConstraint.TEN_SECONDS} and its {@code defaultPollInterval} is a fixed 100ms
* &mdash; both read directly from {@code Awaitility.java} and {@code AtMostWaitConstraint.java}
* in the 4.3.0 sources jar. Neither test overrides {@code atMost(...)} or
* {@code pollInterval(...)}.
*/
class DefaultTimingTest {
@Test
void theDefaultTimeoutIsTenSeconds() {
long start = System.nanoTime();
assertThatTimeoutIsThrownBy(() -> await().until(() -> false));
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
// Allow scheduling jitter; the point is "about 10 seconds", not exactly 10,000ms.
assertThat(elapsedMs).isBetween(9_000L, 11_000L);
Capture.write("03-default-timeout-is-ten-seconds.txt",
"await().until(() -> false) with no atMost(...) override",
"elapsed before ConditionTimeoutException: %dms (expected: ~10,000ms)".formatted(elapsedMs));
}
@Test
void theDefaultPollIntervalIsOneHundredMillis() {
List<Long> pollTimestampsMs = new ArrayList<>();
long start = System.nanoTime();
assertThatTimeoutIsThrownBy(() -> await()
.atMost(java.time.Duration.ofMillis(550))
.until(() -> {
pollTimestampsMs.add((System.nanoTime() - start) / 1_000_000);
return false;
}));
List<Long> gapsMs = new ArrayList<>();
for (int i = 1; i < pollTimestampsMs.size(); i++) {
gapsMs.add(pollTimestampsMs.get(i) - pollTimestampsMs.get(i - 1));
}
double averageGapMs = gapsMs.stream().mapToLong(Long::longValue).average().orElse(0);
assertThat(averageGapMs).isBetween(80.0, 120.0);
Capture.write("04-default-poll-interval-is-100ms.txt",
"await().atMost(550ms).until(...) with no pollInterval(...) override",
"""
poll timestamps (ms since start): %s
gaps between consecutive polls (ms): %s
average gap: %.1fms (expected: ~100ms)
""".formatted(pollTimestampsMs, gapsMs, averageGapMs));
}
private static void assertThatTimeoutIsThrownBy(Runnable awaitCall) {
try {
awaitCall.run();
throw new AssertionError("expected a ConditionTimeoutException but none was thrown");
}
catch (org.awaitility.core.ConditionTimeoutException expected) {
// the default timeout firing is the point of this test
}
}
}
@@ -1,50 +0,0 @@
package com.ankurm.awaitility;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
/**
* By default Awaitility does <em>not</em> treat an exception thrown inside the polled condition
* as "still false" &mdash; {@code defaultExceptionIgnorer} in {@code Awaitility.java} is a
* predicate that returns {@code false} for every exception, meaning nothing is ignored. An
* exception from the very first poll propagates immediately and fails the test, without waiting
* out the {@code atMost(...)} window at all. docs/output/08-exception-propagates-immediately.txt
* is the real, immediate failure from the version of this test that left
* {@code ignoreExceptionsInstanceOf(...)} out.
*/
@SpringBootTest
class IgnoreExceptionsTest {
@Autowired
private FlakyStartupResource resource;
@Test
void ignoredExceptionsAreTreatedAsNotYetTrue() {
resource.startWarmingUp();
await().atMost(Duration.ofSeconds(1))
.ignoreExceptionsInstanceOf(IllegalStateException.class)
.untilAsserted(() -> assertThat(resource.value()).isEqualTo("ready"));
}
@Test
void capture() {
resource.startWarmingUp();
long start = System.nanoTime();
await().atMost(Duration.ofSeconds(1))
.ignoreExceptionsInstanceOf(IllegalStateException.class)
.untilAsserted(() -> assertThat(resource.value()).isEqualTo("ready"));
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
Capture.write("09-ignore-exceptions-waits-it-out.txt",
"await().ignoreExceptionsInstanceOf(IllegalStateException.class) against a resource that throws for its first 300ms",
"approximate elapsed time before resource.value() finally returned \"ready\": %dms (expected: a little over 300ms)"
.formatted(elapsedMs));
}
}
@@ -1,78 +0,0 @@
package com.ankurm.awaitility;
import java.time.Duration;
import java.util.Map;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import org.springframework.kafka.test.EmbeddedKafkaBroker;
import org.springframework.kafka.test.context.EmbeddedKafka;
import org.springframework.kafka.test.utils.KafkaTestUtils;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
/**
* {@code @EmbeddedKafka} starts a real, in-process Kafka broker for the duration of this test
* class &mdash; no Docker, no Testcontainers. {@link OrderEventListener#onOrderEvent(String)}
* consumes on the listener container's own thread, so sending a record with
* {@link KafkaTemplate#send} and then immediately reading {@code received()} is racing the
* consumer's poll loop exactly the way docs/02-the-self-invocation-trap.md's proxy call races a
* background thread, just with a network hop (loopback, but still a hop) in between.
*/
@SpringBootTest
@ActiveProfiles("test")
@TestPropertySource(properties = "app.kafka-demo.enabled=true")
@EmbeddedKafka(partitions = 1, topics = "orders")
class KafkaListenerAwaitTest {
@Autowired
private OrderEventListener listener;
@Autowired
private EmbeddedKafkaBroker embeddedKafkaBroker;
@Test
void messageSentIsEventuallyConsumed() {
listener.received().clear();
KafkaTemplate<String, String> producer = newProducer();
producer.send("orders", "order-42-created");
await().atMost(Duration.ofSeconds(5))
.untilAsserted(() -> assertThat(listener.received()).contains("order-42-created"));
}
@Test
void capture() {
listener.received().clear();
KafkaTemplate<String, String> producer = newProducer();
long start = System.nanoTime();
producer.send("orders", "order-77-created");
await().atMost(Duration.ofSeconds(5))
.untilAsserted(() -> assertThat(listener.received()).contains("order-77-created"));
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
Capture.write("05-kafka-listener-await.txt",
"await() for an @KafkaListener to consume a record a KafkaTemplate just sent, against an @EmbeddedKafka broker",
"""
listener.received() immediately after send() returned: (not checked -- that is the point)
listener.received() once await() returned: %s
approximate elapsed time: %dms
""".formatted(listener.received(), elapsedMs));
}
private KafkaTemplate<String, String> newProducer() {
Map<String, Object> props = KafkaTestUtils.producerProps(embeddedKafkaBroker);
props.put(ProducerConfig.ACKS_CONFIG, "all");
ProducerFactory<String, String> producerFactory = new DefaultKafkaProducerFactory<>(props);
return new KafkaTemplate<>(producerFactory);
}
}
@@ -1,53 +0,0 @@
package com.ankurm.awaitility;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
/**
* {@link ReportJob} runs on its own {@code fixedRate = 150} schedule, starting the moment the
* context comes up &mdash; including however long context startup itself took, which varies by
* machine and by whether this is the first test in the module or the tenth reusing a cached
* context. "Sleep for 3 * 150ms and assert runCount() == 3" is exactly as wrong here as it was
* for the {@code @Async} case, for the same reason: the guess has to be right about a timing
* detail the test does not control. This test instead waits for <em>at least three more runs
* than there were when the test started</em>, which is true regardless of how long the job has
* already been running.
*/
@SpringBootTest
class ScheduledJobAwaitTest {
@Autowired
private ReportJob reportJob;
@Test
void atLeastThreeMoreRunsHappenWithinAwaitWindow() {
int baseline = reportJob.runCount();
await().atMost(Duration.ofSeconds(2))
.untilAsserted(() -> assertThat(reportJob.runCount()).isGreaterThanOrEqualTo(baseline + 3));
}
@Test
void capture() {
int baseline = reportJob.runCount();
long start = System.nanoTime();
await().atMost(Duration.ofSeconds(2))
.untilAsserted(() -> assertThat(reportJob.runCount()).isGreaterThanOrEqualTo(baseline + 3));
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
Capture.write("06-scheduled-job-await.txt",
"await() for 3 more @Scheduled(fixedRate = 150) executions, regardless of how long the job had already been running",
"""
runCount() when the test started: %d
target (baseline + 3): %d
runCount() once await() returned: %d
approximate elapsed time: %dms
""".formatted(baseline, baseline + 3, reportJob.runCount(), elapsedMs));
}
}
@@ -1,49 +0,0 @@
package com.ankurm.awaitility;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
/**
* A fact from reading {@code ConditionAwaiter.java}, not from the user guide: with
* {@code catchUncaughtExceptions} at its default of {@code true}, every {@code await(...)} call
* installs <em>itself</em> as the JVM's default {@link Thread#setDefaultUncaughtExceptionHandler}
* for as long as that one call is polling, then restores whatever handler was there before.
* That is a process-wide setting, not a per-thread one &mdash; a test that installs its own
* default handler and then calls {@code await()} will have it silently replaced until the
* {@code await()} call returns.
*/
class UncaughtExceptionHandlerSwapTest {
@Test
void awaitTemporarilyInstallsItsOwnDefaultHandlerThenRestoresTheOriginal() {
Thread.UncaughtExceptionHandler original = (t, e) -> {
};
Thread.setDefaultUncaughtExceptionHandler(original);
AtomicReference<Thread.UncaughtExceptionHandler> handlerDuringAwait = new AtomicReference<>();
await().atMost(Duration.ofMillis(300)).until(() -> {
handlerDuringAwait.set(Thread.getDefaultUncaughtExceptionHandler());
return true;
});
Thread.UncaughtExceptionHandler handlerAfterAwait = Thread.getDefaultUncaughtExceptionHandler();
assertThat(handlerDuringAwait.get()).isNotSameAs(original);
assertThat(handlerAfterAwait).isSameAs(original);
Capture.write("07-uncaught-exception-handler-swap.txt",
"Thread.getDefaultUncaughtExceptionHandler() before, during and after one await() call",
"""
handler during await() is the one this test installed: %s (expected: false)
handler during await() is Awaitility's own (class %s)
handler after await() is the one this test installed: %s (expected: true)
""".formatted(handlerDuringAwait.get() == original,
handlerDuringAwait.get().getClass().getName(),
handlerAfterAwait == original));
}
}
@@ -1,10 +0,0 @@
spring:
kafka:
bootstrap-servers: ${spring.embedded.kafka.brokers}
consumer:
auto-offset-reset: earliest
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
@@ -1,4 +0,0 @@
target/
*.class
.idea/
*.iml
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Ankur Mhatre
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -1,65 +0,0 @@
# virtual-threads-webflux-benchmark
Companion module for the ankurm.com post **"Virtual Threads vs Reactive (WebFlux) vs Platform
Threads: Benchmarks and a Decision Framework."** This module is the WebFlux leg of that
three-way comparison; the platform-thread and virtual-thread legs live in the sibling module
[`../virtual-threads-benchmark`](../virtual-threads-benchmark), built for a different
ankurm.com post and reused here rather than re-run, so the platform/virtual numbers quoted in
this post are the same numbers, not a second measurement of the same thing.
Every number below is from a real concurrent load run against a real running embedded Netty
server on this JDK, using the identical client-side load generator
(`java.net.http.HttpClient`, virtual-thread executor, client-side only) as the sibling module.
## Versions (verified against `repo1.maven.org` maven-metadata.xml and Spring Boot's own `spring-boot-dependencies` POM, not aggregators)
| Component | Version | Notes |
|---|---|---|
| JDK | 25 (Temurin 25.0.4.1+1) | same as `../virtual-threads-benchmark` |
| Spring Boot | 4.1.1 | latest GA at time of writing |
| Spring Framework | 7.0.9 | latest GA |
| Reactor | managed by `spring-boot-dependencies` 4.1.1 | version not pinned directly; see this module's effective POM |
## Quickstart
```bash
./scripts/run-all.sh # regenerates every file in docs/output/ from a real test run
./scripts/run.sh # start on :8080
```
Requires JDK 25 and Maven. This module's own benchmark ran on the same 2 vCPU sandbox as
`../virtual-threads-benchmark` -- see
[docs/01-webflux-benchmark-methodology.md](docs/01-webflux-benchmark-methodology.md) for why
that matters and what it means for the numbers below.
## What's demonstrated where
| Area | Source | Test | Transcript |
|---|---|---|---|
| I/O-bound throughput: WebFlux, 600 concurrent, median of 5 trials (single trials swung 50%+ on this box) | [`ReactiveDemoController`](src/main/java/com/ankurm/vthreadswebflux/ReactiveDemoController.java) | [`WebfluxLoadBenchmarkTest`](src/test/java/com/ankurm/vthreadswebflux/WebfluxLoadBenchmarkTest.java) | [`01`](docs/output/01-io-bound-webflux.txt) |
| CPU-bound throughput: naive (event loop) vs offloaded (`Schedulers.parallel()`), 60 concurrent -- and why the gap is smaller than expected on this box | same | same | [`02`](docs/output/02-cpu-bound-webflux.txt) |
| Event-loop starvation: what the naive CPU endpoint actually costs *other* traffic on the same server, 150 concurrent, median of 3 trials | [`CpuWork`](src/main/java/com/ankurm/vthreadswebflux/CpuWork.java) | same | [`03`](docs/output/03-event-loop-starvation.txt) |
| Backpressure is structural: a `Flux` never outruns its subscriber's requests | [`ReactiveDemoController.streamResults()`](src/main/java/com/ankurm/vthreadswebflux/ReactiveDemoController.java) | [`StreamBackpressureTest`](src/test/java/com/ankurm/vthreadswebflux/StreamBackpressureTest.java) | [`04`](docs/output/04-stream-backpressure.txt) |
## Documentation chapters
1. [WebFlux benchmark methodology and results](docs/01-webflux-benchmark-methodology.md) --
I/O-bound, CPU-bound naive vs offloaded, the event-loop-starvation scenario the isolated CPU
benchmark can't show (including a first-cut version of that test that under-loaded the event
loop and had to be fixed), a real backpressure proof, and the discovery that single-trial
measurements on this sandbox swing 50%+ and had to be replaced with medians of several
trials, all on this sandbox's real hardware
## A note on this module's relationship to `../virtual-threads-benchmark`
[`../virtual-threads-benchmark`](../virtual-threads-benchmark) already contains a real,
re-run platform-thread vs virtual-thread benchmark on this exact hardware and JDK, built for
[Virtual Threads on Spring Boot 4.1](https://ankurm.com/leveraging-virtual-threads-in-spring-boot-3-4-building-high-throughput-services/).
This module deliberately does not re-measure that comparison -- it reuses those committed
transcripts and adds only the WebFlux leg, using the same client-side load-generation method,
so the three-way post can quote one consistent measurement approach across all three models
instead of stitching together benchmarks run different ways.
## License
MIT -- see [LICENSE](LICENSE).
@@ -1,187 +0,0 @@
# 1. WebFlux benchmark methodology and results
[README](../README.md) | Companion module: [`../virtual-threads-benchmark`](../virtual-threads-benchmark/README.md) (platform threads vs virtual threads)
Source: [`ReactiveDemoController.java`](../src/main/java/com/ankurm/vthreadswebflux/ReactiveDemoController.java), [`CpuWork.java`](../src/main/java/com/ankurm/vthreadswebflux/CpuWork.java).
Test: [`WebfluxLoadBenchmarkTest.java`](../src/test/java/com/ankurm/vthreadswebflux/WebfluxLoadBenchmarkTest.java).
Transcripts: [`docs/output/01-io-bound-webflux.txt`](output/01-io-bound-webflux.txt), [`docs/output/02-cpu-bound-webflux.txt`](output/02-cpu-bound-webflux.txt), [`docs/output/03-event-loop-starvation.txt`](output/03-event-loop-starvation.txt), [`docs/output/04-stream-backpressure.txt`](output/04-stream-backpressure.txt).
## Why this module exists, and why it's separate from `../virtual-threads-benchmark`
The companion post for this module is the three-way comparison: platform threads vs virtual
threads vs WebFlux. The platform-vs-virtual half of that comparison already has a real,
re-run benchmark in [`../virtual-threads-benchmark`](../virtual-threads-benchmark), built for
a different ankurm.com post and reused here rather than duplicated. This module adds the
missing WebFlux leg, measured with the identical client-side load generator
(`java.net.http.HttpClient` backed by a virtual-thread executor, used only as the *client*) so
all three legs come from the same method on the same 2 vCPU sandbox.
It is its own Maven module rather than a third profile inside `../virtual-threads-benchmark`
because `spring-boot-starter-web` (Tomcat) and `spring-boot-starter-webflux` (Netty) on the
same classpath fight over `WebApplicationType.deduceFromClasspath()` -- exactly the kind of
fragile setup a benchmark should not carry. A module with only `spring-boot-starter-webflux`
needs no such workaround.
## A methodology correction made while building this module: single trials are not reliable here
While measuring the I/O-bound endpoint, identical back-to-back single trials at 600
concurrency swung from 795ms to 1247ms wall time on this shared sandbox -- a spread larger
than the actual gap this benchmark exists to measure. A single HTTP load-test run on a
noisy, shared 2 vCPU box is not precise enough to support a "X% faster" claim between two
models that are actually close; it only looks precise because it produces one number.
The fix applied to the I/O-bound scenario and the event-loop-starvation scenario below:
run the load multiple independent times and report the **median** across trials, not a
single shot. This is a real methodology change, not cosmetic -- it changed which model's
number looked better in earlier drafts of this module before the fix was applied.
**This has one important consequence for how to read the numbers below.** The
platform-thread and virtual-thread numbers reused from
[`../virtual-threads-benchmark`](../virtual-threads-benchmark) are **single trials**, measured
for a different post before this variance was discovered there. The WebFlux numbers in this
module are **medians of 5 (I/O) or 3 (event-loop starvation) trials**. Comparing a
denoised median against a single trial is not perfectly apples-to-apples: a precise
percentage gap between WebFlux and virtual threads should be read as directional, not as a
figure you could reproduce to the point. A gap wide enough to swamp the observed ~50%
single-trial swing -- which is the case for every comparison against platform threads in
this post, and turned out to be the case for WebFlux vs. virtual threads too once
de-noised -- is the part worth trusting.
## I/O-bound: `/io`, `Mono.delay(300ms)`, concurrency 600, median of 5 trials
```
webflux (netty) : total=600 success=600 wall=569ms p50=455ms p99=510ms
```
`Mono.delay()` never parks a thread -- the event loop schedules a timer callback for 300ms
later and immediately returns to the selector loop to service other connections. Against the
single-trial platform/virtual-thread numbers in
[`../virtual-threads-benchmark/docs/output/01-io-bound-benchmark.txt`](../virtual-threads-benchmark/docs/output/01-io-bound-benchmark.txt)
(`platform wall=1325ms p50=733ms p99=1201ms`, `virtual wall=1042ms p50=658ms p99=720ms`),
WebFlux's median-of-5 number is faster on all three metrics: roughly 55-58% faster than
platform threads and 29-45% faster than virtual threads, depending on the metric. Platform
threads being clearly worse is a robust finding -- even the noisiest single WebFlux trial
observed while building this module (1247ms) still beats platform threads' 1325ms. The
WebFlux-vs-virtual-threads gap specifically should be read with the single-trial-vs-median
caveat above in mind, and its exact size moved by roughly 5-10% between the last two
verification runs of this same median-of-5 measurement -- both models avoid Tomcat's
thread-pool queueing entirely and are dramatically faster than platform threads for this
workload, which is the reproducible part of this result.
## CPU-bound: `/cpu` (naive) vs `/cpu-offloaded`, concurrency 60
This is the section worth reading slowly, because the first, most intuitive prediction --
"blocking the event loop must be dramatically worse" -- is not what the isolated benchmark
shows on this hardware, and the reason why is a real, checkable fact about this JVM rather
than noise.
```
LoopResources.DEFAULT_IO_WORKER_COUNT (Netty event-loop threads) = 4
Runtime.availableProcessors() (Schedulers.parallel() thread count) = 2
webflux naive (Mono.fromCallable, no subscribeOn) : total=60 success=60 wall=302ms p50=145ms p99=288ms
webflux offloaded (subscribeOn(Schedulers.parallel())) : total=60 success=60 wall=271ms p50=142ms p99=261ms
```
`/cpu` wraps the identical 20,000x SHA-256 loop from
[`../virtual-threads-benchmark`'s `DemoController`](../virtual-threads-benchmark/src/main/java/com/ankurm/vthreads/DemoController.java)
in `Mono.fromCallable()` with no `subscribeOn(...)` -- the single most common way a WebFlux
handler ends up doing real work -- so it runs on whichever thread subscribes to the `Mono`:
the Netty event-loop thread that received the request. `/cpu-offloaded` moves the identical
work to `Schedulers.parallel()`, the scheduler Reactor's own documentation recommends for
CPU-bound work (not `Schedulers.boundedElastic()`, which exists for blocking I/O and is sized
far larger than the core count).
<blockquote>Reactor Netty's default event-loop pool
(<code>LoopResources.DEFAULT_IO_WORKER_COUNT</code>, confirmed by printing the constant
directly rather than reading it off documentation) is <code>max(availableProcessors(), 4)</code>
-- 4 threads on this 2-core box. <code>Schedulers.parallel()</code>'s default pool is sized to
<code>availableProcessors()</code> -- 2 threads on the same box, confirmed the same way. The
endpoint that "incorrectly" runs on the event loop has <em>more</em> worker threads available
to it, at this concurrency, than the "correctly offloaded" one. That is why offloaded is only
roughly 2-10% faster across these three metrics rather than showing a dramatic gap -- not
because the advice to offload CPU work is wrong.</blockquote>
Offloaded is faster here on every metric, consistent with the advice, but the margin alone
understates why the naive version is still wrong. See the next section for the failure this
endpoint-level benchmark cannot show.
## What the isolated CPU benchmark hides: event-loop starvation of *other* traffic
```
/io alone (baseline, no concurrent CPU load) : total=20 success=20 wall=347ms p50=332ms p99=346ms
/io while 150x /cpu (naive) run concurrently : total=20 success=20 wall=995ms p50=742ms p99=950ms
/io while 150x /cpu-offloaded run concurrently : total=20 success=20 wall=731ms p50=693ms p99=697ms
```
(median of 3 trials each; see the methodology correction above for why)
This is the real cost of the naive endpoint, and benchmarking `/cpu` by itself cannot show it:
`/io` and `/cpu` share the same small event-loop pool. Firing concurrent `/cpu` requests and,
while they are still in flight, firing 20 unrelated `/io` requests at the same server
measures what happens to traffic that has nothing to do with the CPU-bound endpoint.
**Getting a reproducible number here took two fixes, not one.** This test's first cut used
only 8 concurrent `/cpu` requests and produced an inconsistent, sometimes-inverted result
across repeated runs: 8 requests drain through 4 event-loop threads in two short rounds,
finishing well before the `/io` measurement window was over. Raising the load to 60
concurrent requests (matching the CPU-bound benchmark's own concurrency) fixed the
under-loading problem but still left a margin thin enough to flake once during verification
(372ms vs 374ms p99 -- a real tie, not a real result). The final fix was **150 concurrent
`/cpu` requests, median of 3 trials** -- both changes, not a threshold that happened to pass
once.
**A second, more interesting finding came out of raising the load this high**: at 150
concurrent CPU-bound requests -- far beyond the 2 physical cores available -- *even the
offloaded case* now degrades `/io` noticeably (baseline p99 346ms vs. offloaded-load p99
697ms, roughly 2x). Offloading moves the CPU work off the event loop's 4 threads onto
`Schedulers.parallel()`'s 2 threads, which stops it from directly starving `/io`'s access to
the event loop -- but it does not stop it from saturating the 2 physical cores those event-loop
threads still need CPU time on. Naive is still clearly worse than offloaded (roughly 7-36%
worse across wall/p50/p99, varying by metric and by trial), which is the mechanism this test
is built to demonstrate; it is just not a free pass to "offloaded means no impact at all" once
concurrent CPU load exceeds what the box can actually run at once. At higher production
concurrency, both the direct mechanism (naive holding event-loop threads) and this indirect
one (any CPU-bound load competing for the same physical cores) matter.
<ul>
<li>If you're auditing your own WebFlux service: grep for <code>Mono.fromCallable</code>,
<code>Flux.fromIterable</code> wrapping computation, or any synchronous call inside a reactive
chain with no <code>subscribeOn(...)</code> after it -- each one runs on the event loop by
default.</li>
<li><code>Schedulers.parallel()</code> for CPU-bound work, <code>Schedulers.boundedElastic()</code>
for blocking I/O you can't make non-blocking -- they are sized for different jobs and using the
wrong one either wastes threads or defeats the point of offloading.</li>
<li>Reactor Netty event-loop sizing: <a href="https://projectreactor.io/docs/netty/release/reference/index.html#_event_loop_workers" rel="nofollow">projectreactor.io/docs/netty</a></li>
</ul>
## Backpressure is structural, not a benchmark number
`/stream` ([`ReactiveDemoController.streamResults()`](../src/main/java/com/ankurm/vthreadswebflux/ReactiveDemoController.java))
and [`StreamBackpressureTest`](../src/test/java/com/ankurm/vthreadswebflux/StreamBackpressureTest.java)
exist to check a claim this kind of post usually just asserts: that a `Flux` never emits faster
than its subscriber requests. The test drives the endpoint's `Flux` with `StepVerifier`,
requesting 3 items, then 2 more, then the remaining 45, and asserts no item ever arrives ahead
of a pending request:
```
StepVerifier.create(controller.streamResults(), 3)
.expectNext("event-0", "event-1", "event-2")
.expectNoEvent(Duration.ofMillis(80)) -- no 4th item arrives without a request
.thenRequest(2).expectNext("event-3", "event-4")
.thenRequest(45).expectNextCount(45)
.expectComplete()
```
(`docs/output/04-stream-backpressure.txt`) The endpoint is built on `Flux.range()` rather than
the more "realistic-looking" `Flux.interval()` on purpose: `interval()` ticks on its own
wall-clock schedule independent of downstream demand, and this test's first draft, written
against an `interval()`-based endpoint, failed immediately with
`OverflowException: Could not emit tick 3 due to lack of requests` the moment the subscriber's
initial request of 3 ran out before the next scheduled tick. That failure is itself informative:
`interval()` is not a safe way to demonstrate backpressure, because it can be forced into an
error state by a slow-enough subscriber, which is the opposite of the point. `range()` has no
independent production schedule, so it can never outrun demand -- it is what this repo actually
uses, and what the assertion above actually verifies.
Back to [README](../README.md).
@@ -1,12 +0,0 @@
I/O-bound endpoint (/io, Mono.delay(300ms)), concurrency=600, median of 5 trials, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK 25.0.4.1
==================================================================================================================================
webflux (netty) : total=600 success=600 wall=569ms p50=455ms p99=510ms
Mono.delay() never parks a thread -- the event loop schedules a timer callback and
goes back to the selector loop immediately. A single trial at this concurrency swung
50%+ between back-to-back runs on this shared sandbox -- bigger than the gap being
measured -- so the number above is the median of 5 independent trials, not
one shot. Even so it lands in the same range as virtual threads' /io result in
../virtual-threads-benchmark/docs/output/01-io-bound-benchmark.txt (a single trial):
treat any single-run percentage gap between WebFlux and virtual threads here as
noise-level, not a reliable ranking.
@@ -1,17 +0,0 @@
CPU-bound endpoint (/cpu vs /cpu-offloaded, 20,000x SHA-256), concurrency=60, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK 25.0.4.1
==============================================================================================================================
LoopResources.DEFAULT_IO_WORKER_COUNT (Netty event-loop threads) = 4
Runtime.availableProcessors() (Schedulers.parallel() thread count) = 2
webflux naive (Mono.fromCallable, no subscribeOn) : total=60 success=60 wall=302ms p50=145ms p99=288ms
webflux offloaded (subscribeOn(Schedulers.parallel())) : total=60 success=60 wall=271ms p50=142ms p99=261ms
Counter-intuitive result, and worth stating honestly rather than forcing the expected
story: on THIS box, the two wall times are close, because Reactor Netty's default event-
loop pool (DEFAULT_IO_WORKER_COUNT = max(availableProcessors(), 4) = 4 here) is actually
LARGER than Schedulers.parallel()'s pool (sized to availableProcessors() = 2). The naive
endpoint that "incorrectly" runs on the event loop has more worker threads to run on,
at this modest concurrency, than the "correctly offloaded" one. This does not mean the
naive version is fine -- see the event-loop-starvation scenario below for what it actually
breaks -- only that per-endpoint throughput alone does not show the problem on a small,
under-loaded box like this one.
@@ -1,19 +0,0 @@
/io latency while 150 concurrent CPU-bound requests run, median of 3 trials, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK 25.0.4.1
=============================================================================================================================
/io alone (baseline, no concurrent CPU load) : total=20 success=20 wall=347ms p50=332ms p99=346ms
/io while 150x /cpu (naive) run concurrently : total=20 success=20 wall=995ms p50=742ms p99=950ms
/io while 150x /cpu-offloaded run concurrently : total=20 success=20 wall=731ms p50=693ms p99=697ms
This is the real cost of the naive endpoint, and it does not show up by benchmarking
/cpu in isolation: /io shares the same small event-loop pool with /cpu. Two fixes were
needed to get a reproducible number here rather than a coin flip: enough concurrent CPU
load to occupy all 4 event-loop threads for the full /io measurement window (a first
cut used 8 concurrent requests, which drained through the event loop in well under the
/io window and produced an inconsistent, sometimes-inverted result; 60 concurrent
requests fixed that but still flaked once, 372ms vs 374ms p99, a real tie rather than a
real result), and taking the median of 3 independent trials rather than one shot,
same reasoning as the I/O-bound benchmark above. With both fixes, naive /io latency is
consistently and substantially worse than both the undisturbed baseline and the
offloaded case. At higher production concurrency this is the exact mechanism behind a
single CPU-heavy endpoint silently degrading every other endpoint on the same Netty
server.
@@ -1,13 +0,0 @@
Flux backpressure proof: /stream, requested in batches of 3, 2, then 45
=======================================================================
StepVerifier.create(controller.streamResults(), 3)
.expectNext("event-0", "event-1", "event-2")
.expectNoEvent(Duration.ofMillis(80)) -- no 4th item arrives without a request
.thenRequest(2).expectNext("event-3", "event-4")
.thenRequest(45).expectNextCount(45)
.expectComplete()
RESULT: verified -- the flux emitted exactly as many items as were requested, in
the order requested, with no items arriving ahead of a pending request. This is
what "backpressure is part of the Flux contract" means concretely: the subscriber,
not the producer, controls the emission rate.
-60
View File
@@ -1,60 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- Same parent version as ../virtual-threads-benchmark and ../async: Boot 4.1.1 manages
Spring Framework 7.0.9 and Reactor 2024.x. Versions read from spring-boot-dependencies-4.1.1.pom,
not from release notes. Deliberately its own module rather than a profile inside
../virtual-threads-benchmark: spring-boot-starter-web (Tomcat) and spring-boot-starter-webflux
(Netty) fighting over WebApplicationType.deduceFromClasspath() in one module is exactly the kind
of fragile setup this benchmark exists to avoid; a separate module with only starter-webflux on
its classpath needs no such trick. -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>virtual-threads-webflux-benchmark</artifactId>
<version>1.0.0</version>
<name>virtual-threads-webflux-benchmark</name>
<description>The WebFlux leg of the platform-threads / virtual-threads / WebFlux three-way benchmark, re-run on Spring Boot 4.1.1 / JDK 25</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -1,10 +0,0 @@
#!/usr/bin/env bash
# Regenerates every file under docs/output/ from a real run: the JUnit test suite writes
# 01, 02 and 03 via the Transcript helper as it asserts. See docs/01-webflux-benchmark-methodology.md.
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -q -B test
echo "Regenerated:"
ls -1 docs/output/
@@ -1,7 +0,0 @@
#!/usr/bin/env bash
# Starts the WebFlux app on :8080 to poke at by hand.
# ./scripts/run.sh
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -q -B spring-boot:run
@@ -1,29 +0,0 @@
package com.ankurm.vthreadswebflux;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* The exact same 20,000x SHA-256 loop as {@code DemoController.cpu()} in the sibling
* ../virtual-threads-benchmark module -- same iteration count, same seed bytes -- so the
* platform-thread, virtual-thread, and WebFlux legs of this benchmark all pay an identical
* per-request CPU cost. Only the thread model serving the request differs.
*/
final class CpuWork {
private CpuWork() {
}
static String run() {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] data = "spring-boot-4.1-virtual-threads-benchmark".getBytes();
for (int i = 0; i < 20_000; i++) {
data = md.digest(data);
}
return "cpu:" + Thread.currentThread() + ":" + data.length;
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -1,75 +0,0 @@
package com.ankurm.vthreadswebflux;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.time.Duration;
/**
* The WebFlux leg of the three-way benchmark in docs/01-webflux-benchmark-methodology.md.
* Mirrors ../virtual-threads-benchmark's DemoController endpoint-for-endpoint so the numbers
* are directly comparable: /io is the same 300ms simulated downstream call, /cpu and
* /cpu-offloaded both run the identical CpuWork loop the sibling module's /cpu endpoint runs.
*
* /cpu and /cpu-offloaded exist as a pair on purpose. /cpu wraps blocking CPU work in
* Mono.fromCallable() with no subscribeOn -- the single most common way a WebFlux handler ends
* up doing real work -- which runs the callable on whichever thread subscribes to the Mono:
* the Netty event-loop thread that received the request. /cpu-offloaded moves the same work to
* Schedulers.parallel() (sized to availableProcessors(), the scheduler Reactor's own
* documentation recommends for CPU-bound work -- not Schedulers.boundedElastic(), which exists
* for blocking I/O and is sized far larger than the core count). See
* docs/01-webflux-benchmark-methodology.md for what each does to the event loop under load.
*/
@RestController
public class ReactiveDemoController {
@GetMapping("/io")
public Mono<String> io() {
return Mono.delay(Duration.ofMillis(300))
.map(tick -> "io:" + Thread.currentThread());
}
@GetMapping("/cpu")
public Mono<String> cpu() {
// No subscribeOn: runs on the calling (event-loop) thread. This is the naive version.
return Mono.fromCallable(CpuWork::run);
}
@GetMapping("/cpu-offloaded")
public Mono<String> cpuOffloaded() {
return Mono.fromCallable(CpuWork::run)
.subscribeOn(Schedulers.parallel());
}
@GetMapping("/thread-info")
public Mono<String> threadInfo() {
return Mono.fromSupplier(() -> {
Thread t = Thread.currentThread();
return "Thread: " + t + " | Virtual: " + t.isVirtual();
});
}
/**
* Backpressure is part of the Flux contract, not something bolted on: the subscriber
* (StepVerifier in {@code StreamBackpressureTest}, or a real HTTP client requesting N items
* at a time) controls how many items are pulled, and Reactor never emits more than that. See
* docs/output/04-stream-backpressure.txt for a real run proving items beyond what was
* requested never arrive.
*
* Built on Flux.range() rather than Flux.interval(): interval() ticks on its own wall-clock
* schedule independent of downstream demand and throws OverflowException the moment a slow
* subscriber's outstanding request count runs out before the next tick -- discovered by this
* module's own StreamBackpressureTest failing with exactly that error on its first run.
* range() has no independent production schedule, so it can never outrun demand; that
* property, not the specific numbers it emits, is what the test below verifies.
*/
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamResults() {
return Flux.range(0, 50)
.map(i -> "event-" + i);
}
}
@@ -1,12 +0,0 @@
package com.ankurm.vthreadswebflux;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class VirtualThreadsWebfluxBenchmarkApplication {
public static void main(String[] args) {
SpringApplication.run(VirtualThreadsWebfluxBenchmarkApplication.class, args);
}
}
@@ -1,16 +0,0 @@
spring:
application:
name: virtual-threads-webflux-benchmark
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,metrics
logging:
level:
com.ankurm.vthreadswebflux: INFO
@@ -1,50 +0,0 @@
package com.ankurm.vthreadswebflux;
import org.junit.jupiter.api.Test;
import reactor.test.StepVerifier;
import java.time.Duration;
/**
* Proves the backpressure claim in docs/01-webflux-benchmark-methodology.md is real rather
* than asserted: a subscriber that requests only 3 items at a time never receives a 4th until
* it asks. StepVerifier.create(flux, 3) starts the subscription with an initial request of 3
* (not unbounded, which is StepVerifier's default) -- if ReactiveDemoController.streamResults()
* ignored backpressure and pushed everything immediately, this test would see events beyond
* the first 3 before the additional .thenRequest(...) calls run, and StepVerifier would fail
* the sequence.
*
* Output: docs/output/04-stream-backpressure.txt.
*/
class StreamBackpressureTest {
@Test
void subscriberControlsEmissionRate() {
ReactiveDemoController controller = new ReactiveDemoController();
StepVerifier.create(controller.streamResults(), 3)
.expectNext("event-0", "event-1", "event-2")
.expectNoEvent(Duration.ofMillis(80)) // no 4th item until we ask for one
.thenRequest(2)
.expectNext("event-3", "event-4")
.thenRequest(45)
.expectNextCount(45)
.expectComplete()
.verify(Duration.ofSeconds(10));
Transcript t = Transcript.start("04-stream-backpressure.txt",
"Flux backpressure proof: /stream, requested in batches of 3, 2, then 45");
t.line("StepVerifier.create(controller.streamResults(), 3)");
t.line(" .expectNext(\"event-0\", \"event-1\", \"event-2\")");
t.line(" .expectNoEvent(Duration.ofMillis(80)) -- no 4th item arrives without a request");
t.line(" .thenRequest(2).expectNext(\"event-3\", \"event-4\")");
t.line(" .thenRequest(45).expectNextCount(45)");
t.line(" .expectComplete()");
t.blank();
t.line("RESULT: verified -- the flux emitted exactly as many items as were requested, in");
t.line("the order requested, with no items arriving ahead of a pending request. This is");
t.line("what \"backpressure is part of the Flux contract\" means concretely: the subscriber,");
t.line("not the producer, controls the emission rate.");
t.save();
}
}
@@ -1,50 +0,0 @@
package com.ankurm.vthreadswebflux;
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);
}
}
}
@@ -1,319 +0,0 @@
package com.ankurm.vthreadswebflux;
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 reactor.core.scheduler.Schedulers;
import reactor.netty.resources.LoopResources;
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;
/**
* The WebFlux leg of the three-way benchmark. Uses the identical client-side load generator
* as ../virtual-threads-benchmark's LoadBenchmarkTest (java.net.http.HttpClient backed by a
* virtual-thread executor, used only as the *client*) against this module's Netty/WebFlux
* server instead of Tomcat, so the platform-thread, virtual-thread, and WebFlux numbers in
* the post all come from the same measurement method on the same 2 vCPU sandbox.
*
* Output: docs/output/01-io-bound-webflux.txt, docs/output/02-cpu-bound-webflux.txt.
* See docs/01-webflux-benchmark-methodology.md.
*/
class WebfluxLoadBenchmarkTest {
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);
}
/**
* A single {@link #fireConcurrent} call at 600 concurrency on this shared, noisy sandbox
* swung between 795ms and 1247ms across otherwise-identical back-to-back runs while this
* module was being built -- a larger spread than the actual gap this benchmark is trying
* to measure against the virtual-thread /io numbers. Reporting one trial would have made
* a real effect (or a real non-effect) indistinguishable from sandbox jitter. This runs
* the load {@code trials} independent times and returns the median of each metric across
* trials, which is what is actually reported below and in the post.
*/
private Result fireConcurrentMedian(String baseUrl, String path, int concurrency, int trials) throws Exception {
List<Long> walls = new ArrayList<>();
List<Double> p50s = new ArrayList<>();
List<Double> p99s = new ArrayList<>();
Result last = null;
for (int i = 0; i < trials; i++) {
last = fireConcurrent(baseUrl, path, concurrency);
walls.add(last.wallMs);
p50s.add(last.p50);
p99s.add(last.p99);
}
Collections.sort(walls);
Collections.sort(p50s);
Collections.sort(p99s);
int mid = trials / 2;
return new Result(last.total, last.success, walls.get(mid), p50s.get(mid), p99s.get(mid));
}
@Test
void ioBoundScenario() throws Exception {
int concurrency = 600; // same concurrency as the platform/virtual-thread /io benchmark
int trials = 5;
String baseUrl = startApp();
ConfigurableApplicationContext ctx = currentCtx;
try {
fireConcurrent(baseUrl, "/io", Math.min(concurrency, 30)); // untimed warm-up
Result webflux = fireConcurrentMedian(baseUrl, "/io", concurrency, trials);
Transcript t = Transcript.start("01-io-bound-webflux.txt",
"I/O-bound endpoint (/io, Mono.delay(300ms)), concurrency=" + concurrency
+ ", median of " + trials + " trials, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK "
+ System.getProperty("java.version"));
t.line(String.format("webflux (netty) : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
webflux.total, webflux.success, webflux.wallMs, webflux.p50, webflux.p99));
t.blank();
t.line("Mono.delay() never parks a thread -- the event loop schedules a timer callback and");
t.line("goes back to the selector loop immediately. A single trial at this concurrency swung");
t.line("50%+ between back-to-back runs on this shared sandbox -- bigger than the gap being");
t.line("measured -- so the number above is the median of " + trials + " independent trials, not");
t.line("one shot. Even so it lands in the same range as virtual threads' /io result in");
t.line("../virtual-threads-benchmark/docs/output/01-io-bound-benchmark.txt (a single trial):");
t.line("treat any single-run percentage gap between WebFlux and virtual threads here as");
t.line("noise-level, not a reliable ranking.");
t.save();
assertThat(webflux.success).isEqualTo(concurrency);
} finally {
ctx.close();
}
}
@Test
void cpuBoundScenario() throws Exception {
int concurrency = 60; // same concurrency as the platform/virtual-thread /cpu benchmark
String baseUrl = startApp();
ConfigurableApplicationContext ctx = currentCtx;
try {
// Warm up BOTH code paths through the same JIT-sensitive loop before timing either --
// ../virtual-threads-benchmark/docs/02-benchmark-methodology.md documents the exact
// JIT-warmup trap that first produced a fake 4x result on that module; the same
// MessageDigest.digest hot loop is reused here unmodified, so the same trap applies.
fireConcurrent(baseUrl, "/cpu", Math.min(concurrency, 20));
fireConcurrent(baseUrl, "/cpu-offloaded", Math.min(concurrency, 20));
Result naive = fireConcurrent(baseUrl, "/cpu", concurrency);
Result offloaded = fireConcurrent(baseUrl, "/cpu-offloaded", concurrency);
int ioWorkers = LoopResources.DEFAULT_IO_WORKER_COUNT;
int parallelWorkers = Runtime.getRuntime().availableProcessors();
Transcript t = Transcript.start("02-cpu-bound-webflux.txt",
"CPU-bound endpoint (/cpu vs /cpu-offloaded, 20,000x SHA-256), concurrency=" + concurrency
+ ", 2 vCPU sandbox, Spring Boot 4.1.1 / JDK " + System.getProperty("java.version"));
t.line("LoopResources.DEFAULT_IO_WORKER_COUNT (Netty event-loop threads) = " + ioWorkers);
t.line("Runtime.availableProcessors() (Schedulers.parallel() thread count) = " + parallelWorkers);
t.blank();
t.line(String.format("webflux naive (Mono.fromCallable, no subscribeOn) : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
naive.total, naive.success, naive.wallMs, naive.p50, naive.p99));
t.line(String.format("webflux offloaded (subscribeOn(Schedulers.parallel())) : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
offloaded.total, offloaded.success, offloaded.wallMs, offloaded.p50, offloaded.p99));
t.blank();
t.line("Counter-intuitive result, and worth stating honestly rather than forcing the expected");
t.line("story: on THIS box, the two wall times are close, because Reactor Netty's default event-");
t.line("loop pool (DEFAULT_IO_WORKER_COUNT = max(availableProcessors(), 4) = 4 here) is actually");
t.line("LARGER than Schedulers.parallel()'s pool (sized to availableProcessors() = 2). The naive");
t.line("endpoint that \"incorrectly\" runs on the event loop has more worker threads to run on,");
t.line("at this modest concurrency, than the \"correctly offloaded\" one. This does not mean the");
t.line("naive version is fine -- see the event-loop-starvation scenario below for what it actually");
t.line("breaks -- only that per-endpoint throughput alone does not show the problem on a small,");
t.line("under-loaded box like this one.");
t.save();
assertThat(naive.success).isEqualTo(concurrency);
assertThat(offloaded.success).isEqualTo(concurrency);
} finally {
ctx.close();
}
}
@Test
void eventLoopStarvationScenario() throws Exception {
// Enough concurrent CPU requests to keep all 4 event-loop threads busy for roughly
// as long as the /io measurement window itself -- 8 concurrent requests (this test's
// first cut) drained through 4 event-loop threads in well under the /io window's
// ~300ms, so most of the /io measurement ran with NO concurrent CPU load at all and
// the assertion below flaked in both directions across repeated runs. 60 concurrent
// requests (matching cpuBoundScenario's own concurrency) fixed that, but still left a
// margin thin enough to flake on this shared sandbox (one run measured 372ms vs
// 374ms p99 -- a real tie, not a real result). 150 concurrent requests plus taking the
// median of 3 trials removes that margin instead of chasing a threshold that happens
// to pass once.
int cpuLoadConcurrency = 150;
int trials = 3;
String baseUrl = startApp();
ConfigurableApplicationContext ctx = currentCtx;
try {
// Warm-up through both code paths first, same JIT reason as cpuBoundScenario.
fireConcurrent(baseUrl, "/cpu", 10);
fireConcurrent(baseUrl, "/cpu-offloaded", 10);
fireConcurrent(baseUrl, "/io", 10);
// Baseline: /io alone, no CPU work running concurrently.
Result ioBaseline = fireConcurrentMedian(baseUrl, "/io", 20, trials);
// Fire cpuLoadConcurrency concurrent /cpu (naive) requests and, while they are
// still in flight, fire 20 /io requests at the SAME server -- this is the actual
// failure mode of blocking the event loop: it is not that the CPU endpoint itself
// is slow, it is that the CPU endpoint holds event-loop threads other requests need,
// for as long as it takes the CPU load to drain.
Result ioDuringNaiveCpu = runIoAlongsideCpuMedian(baseUrl, "/cpu", cpuLoadConcurrency, trials);
Result ioDuringOffloadedCpu = runIoAlongsideCpuMedian(baseUrl, "/cpu-offloaded", cpuLoadConcurrency, trials);
Transcript t = Transcript.start("03-event-loop-starvation.txt",
"/io latency while " + cpuLoadConcurrency + " concurrent CPU-bound requests run, median of "
+ trials + " trials, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK "
+ System.getProperty("java.version"));
t.line(String.format("/io alone (baseline, no concurrent CPU load) : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
ioBaseline.total, ioBaseline.success, ioBaseline.wallMs, ioBaseline.p50, ioBaseline.p99));
t.line(String.format("/io while %dx /cpu (naive) run concurrently : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
cpuLoadConcurrency, ioDuringNaiveCpu.total, ioDuringNaiveCpu.success, ioDuringNaiveCpu.wallMs, ioDuringNaiveCpu.p50, ioDuringNaiveCpu.p99));
t.line(String.format("/io while %dx /cpu-offloaded run concurrently : total=%d success=%d wall=%dms p50=%.0fms p99=%.0fms",
cpuLoadConcurrency, ioDuringOffloadedCpu.total, ioDuringOffloadedCpu.success, ioDuringOffloadedCpu.wallMs, ioDuringOffloadedCpu.p50, ioDuringOffloadedCpu.p99));
t.blank();
t.line("This is the real cost of the naive endpoint, and it does not show up by benchmarking");
t.line("/cpu in isolation: /io shares the same small event-loop pool with /cpu. Two fixes were");
t.line("needed to get a reproducible number here rather than a coin flip: enough concurrent CPU");
t.line("load to occupy all 4 event-loop threads for the full /io measurement window (a first");
t.line("cut used 8 concurrent requests, which drained through the event loop in well under the");
t.line("/io window and produced an inconsistent, sometimes-inverted result; 60 concurrent");
t.line("requests fixed that but still flaked once, 372ms vs 374ms p99, a real tie rather than a");
t.line("real result), and taking the median of " + trials + " independent trials rather than one shot,");
t.line("same reasoning as the I/O-bound benchmark above. With both fixes, naive /io latency is");
t.line("consistently and substantially worse than both the undisturbed baseline and the");
t.line("offloaded case. At higher production concurrency this is the exact mechanism behind a");
t.line("single CPU-heavy endpoint silently degrading every other endpoint on the same Netty");
t.line("server.");
t.save();
assertThat(ioBaseline.success).isEqualTo(20);
assertThat(ioDuringNaiveCpu.success).isEqualTo(20);
assertThat(ioDuringOffloadedCpu.success).isEqualTo(20);
// The real, checked claim: naive CPU work on the event loop measurably degrades
// UNRELATED /io traffic on the same server; offloading protects it.
assertThat(ioDuringNaiveCpu.p99).isGreaterThan(ioDuringOffloadedCpu.p99);
} finally {
ctx.close();
}
}
/** Median-of-{@code trials} version of {@link #runIoAlongsideCpu}, for the same noise-floor
* reason documented on {@link #fireConcurrentMedian}. */
private Result runIoAlongsideCpuMedian(String baseUrl, String cpuPath, int cpuConcurrency, int trials) throws Exception {
List<Long> walls = new ArrayList<>();
List<Double> p50s = new ArrayList<>();
List<Double> p99s = new ArrayList<>();
Result last = null;
for (int i = 0; i < trials; i++) {
last = runIoAlongsideCpu(baseUrl, cpuPath, cpuConcurrency);
walls.add(last.wallMs);
p50s.add(last.p50);
p99s.add(last.p99);
}
Collections.sort(walls);
Collections.sort(p50s);
Collections.sort(p99s);
int mid = trials / 2;
return new Result(last.total, last.success, walls.get(mid), p50s.get(mid), p99s.get(mid));
}
/** Fires cpuConcurrency concurrent requests to cpuPath and, without waiting for them,
* fires 20 concurrent /io requests against the same server, returning the /io Result only. */
private Result runIoAlongsideCpu(String baseUrl, String cpuPath, int cpuConcurrency) throws Exception {
HttpClient client = HttpClient.newBuilder()
.executor(Executors.newVirtualThreadPerTaskExecutor())
.build();
// Fire the CPU load in the background, not waited on.
for (int i = 0; i < cpuConcurrency; i++) {
Thread.ofVirtual().start(() -> {
try {
HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + cpuPath)).build();
client.send(req, HttpResponse.BodyHandlers.ofString());
} catch (Exception ignored) {
// best-effort background load
}
});
}
// Give the CPU requests a moment's head start so they are genuinely in flight
// when the /io measurement starts.
Thread.sleep(15);
return fireConcurrent(baseUrl, "/io", 20);
}
private volatile ConfigurableApplicationContext currentCtx;
private String startApp() throws Exception {
AtomicInteger capturedPort = new AtomicInteger(-1);
CountDownLatch portLatch = new CountDownLatch(1);
SpringApplicationBuilder builder = new SpringApplicationBuilder(VirtualThreadsWebfluxBenchmarkApplication.class)
.initializers(ctx -> ctx.addApplicationListener((ApplicationListener<WebServerInitializedEvent>) event -> {
capturedPort.set(event.getWebServer().getPort());
portLatch.countDown();
}));
currentCtx = builder.run("--server.port=0", "--spring.jmx.enabled=false");
portLatch.await(10, TimeUnit.SECONDS);
return "http://localhost:" + capturedPort.get();
}
}
-4
View File
@@ -1,4 +0,0 @@
target/
*.class
.idea/
*.iml
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Ankur Mhatre
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-70
View File
@@ -1,70 +0,0 @@
# virtual-threads-benchmark
Companion module for the ankurm.com post **"Virtual Threads on Spring Boot 4.1: The
Benchmarks, Re-Run, and the Pinning Advice That Expired."** Every number is from a real
concurrent load run against a real running embedded server on this JDK; every pinning claim is
verified against JDK 25's actual runtime behaviour, not carried forward from the JDK 21-era
post it replaces.
Lives in this container repo (not as its own top-level repository) alongside
[`../async`](../async), the companion module for the `@Async`-focused post, which already has a
dual-JDK JEP 491 pinning proof of its own (see the note near the bottom of this file).
## Versions (verified against `repo1.maven.org` maven-metadata.xml and the OpenJDK JEP pages, not aggregators)
| Component | Version | Notes |
|---|---|---|
| JDK | 25 (Temurin 25.0.4.1+1) | latest LTS; includes JEP 491 (GA in JDK 24) and JEP 506 (GA in JDK 25) |
| Spring Boot | 4.1.1 | latest GA at time of writing |
| Spring Framework | 7.0.9 | latest GA |
## Quickstart
```bash
./scripts/run-all.sh # regenerates every file in docs/output/ from a real test run
./scripts/run.sh # start on :8080, platform threads
./scripts/run.sh vt # start on :8080, spring.threads.virtual.enabled=true
```
Requires JDK 25 and Maven. First run must be online (Maven needs to fetch plugins into the
local cache); `-o` works for subsequent builds. This repo's own benchmark ran on a 2 vCPU
sandbox -- see [docs/02-benchmark-methodology.md](docs/02-benchmark-methodology.md) for why
that matters and how the numbers were still kept honest.
## What's demonstrated where
| Area | Source | Test | Transcript |
|---|---|---|---|
| `spring.threads.virtual.enabled` actually changes the request thread type | [`DemoController`](src/main/java/com/ankurm/vthreads/DemoController.java) | [`ThreadTypeTest`](src/test/java/com/ankurm/vthreads/ThreadTypeTest.java) | [`00`](docs/output/00-thread-type-confirmation.txt) |
| I/O-bound throughput: platform vs virtual threads, 600 concurrent | same | [`LoadBenchmarkTest`](src/test/java/com/ankurm/vthreads/LoadBenchmarkTest.java) | [`01`](docs/output/01-io-bound-benchmark.txt) |
| CPU-bound throughput: platform vs virtual threads, 60 concurrent (plus the JIT-warmup benchmarking bug this repo caught and fixed) | same | same | [`02`](docs/output/02-cpu-bound-benchmark.txt) |
| JEP 491 proof: `synchronized` no longer pins across a blocking sleep on JDK 24+ | [`PinningDemoService`](src/main/java/com/ankurm/vthreads/PinningDemoService.java) | [`PinningJep491Test`](src/test/java/com/ankurm/vthreads/PinningJep491Test.java) | [`03a`](docs/output/03a-pinning-jep491-proof.txt) |
| `-Djdk.tracePinnedThreads=full` is inert on JDK 25 | [`PinningTraceCheckMain`](src/main/java/com/ankurm/vthreads/PinningTraceCheckMain.java) | [`check-trace-pinned-threads-removed.sh`](scripts/check-trace-pinned-threads-removed.sh) | [`03b`](docs/output/03b-trace-pinned-threads-removed.txt) |
## Documentation chapters
1. [Enabling virtual threads on Spring Boot 4.1](docs/01-enabling-virtual-threads.md) -- the one
property, what it actually flips, and a `SpringApplicationBuilder` property-precedence trap
this repo's own benchmark hit
2. [Benchmark methodology and results](docs/02-benchmark-methodology.md) -- I/O-bound and
CPU-bound, on this sandbox's real hardware, including a JIT-warmup measurement bug caught
and fixed mid-build
3. [Pinning diagnosis, corrected for JEP 491](docs/03-pinning-diagnosis.md) -- why the standard
`synchronized`-pins-your-carrier advice, and the `-Djdk.tracePinnedThreads=full` flag every
article tells you to use, both stopped being true in JDK 24
4. [ScopedValue, the JDBC driver advice, and a production checklist](docs/04-scoped-value-and-checklist.md)
## A note on this module's relationship to `../async`
[`../async`](../async) is the companion module for a different, `@Async`-focused ankurm.com
post and already contains a dual-JDK (21.0.12.1 vs 25.0.4.1) proof of the same JEP 491 pinning
change, which is stronger evidence than this module can offer on its own (this module only had
JDK 25 available to test against). [Chapter 3](docs/03-pinning-diagnosis.md) here cross-links
to it rather than duplicating it. This module's own scope -- enabling the flag, the I/O/CPU
throughput benchmark, and the diagnosis flag's removal -- doesn't overlap with that module's
`@Async`-specific material, which is why it's kept as its own module here rather than merged
into `async/`.
## License
MIT -- see [LICENSE](LICENSE).
@@ -1,43 +0,0 @@
# 1. Enabling virtual threads on Spring Boot 4.1
[README](../README.md) | Next: [02-benchmark-methodology.md](02-benchmark-methodology.md)
Source: [`application.yml`](../src/main/resources/application.yml), [`DemoController.java`](../src/main/java/com/ankurm/vthreads/DemoController.java).
Test: [`ThreadTypeTest.java`](../src/test/java/com/ankurm/vthreads/ThreadTypeTest.java).
Transcript: [`docs/output/00-thread-type-confirmation.txt`](output/00-thread-type-confirmation.txt).
## Still one property
`spring.threads.virtual.enabled=true` is unchanged since Boot 3.2 and still the whole
configuration surface for most apps on Boot 4.1. It requires JDK 21+; this repo runs it on
JDK 25.
## What actually flips
Confirmed against a real running embedded server, not by reading the reference docs:
```
enabled=false -> Thread: Thread[#9711,http-nio-auto-5-exec-1,5,main] | Virtual: false
enabled=true -> Thread: VirtualThread[#9737,tomcat-handler-0]/runnable@ForkJoinPool-1-worker-2 | Virtual: true
```
The platform-thread name prefix changed between the Boot 3 era and this repo's Boot 4.1 run
(`http-nio-auto-5-exec-1` here vs. the `http-nio-8080-exec-3` shape in the older post) because
this test binds to `server.port=0` (a random port) rather than a fixed 8080 -- Tomcat's
connector-name-derived thread prefix reflects that. Cosmetic, but worth not being surprised by
if your own logs look different from either example.
## Test infrastructure trap worth knowing about
Setting the property via `SpringApplicationBuilder.properties(...)` and expecting it to win
over `application.yml` is a real bug this repo hit while building the benchmark below:
`.properties(...)` binds to Spring's **`defaultProperties`** source, which has the *lowest*
precedence of any property source Spring Boot recognizes -- lower than `application.yml`,
which had `spring.threads.virtual.enabled: false` as its own explicit default. The fix was
passing it as a command-line-style argument to `run(...)` instead
(`"--spring.threads.virtual.enabled=" + value`), which binds at the highest precedence. See
[`LoadBenchmarkTest.java`](../src/test/java/com/ankurm/vthreads/LoadBenchmarkTest.java) for
where this was caught -- the first draft of the CPU-bound benchmark below silently ran both
scenarios on platform threads and reported a difference that didn't exist.
Next: [02-benchmark-methodology.md](02-benchmark-methodology.md).
@@ -1,81 +0,0 @@
# 2. Benchmark methodology and results
[Previous: 01-enabling-virtual-threads.md](01-enabling-virtual-threads.md) | [README](../README.md) | Next: [03-pinning-diagnosis.md](03-pinning-diagnosis.md)
Source: [`DemoController.java`](../src/main/java/com/ankurm/vthreads/DemoController.java).
Test: [`LoadBenchmarkTest.java`](../src/test/java/com/ankurm/vthreads/LoadBenchmarkTest.java).
Transcripts: [`docs/output/01-io-bound-benchmark.txt`](output/01-io-bound-benchmark.txt), [`docs/output/02-cpu-bound-benchmark.txt`](output/02-cpu-bound-benchmark.txt).
## Why this repo doesn't reuse the old post's numbers
The version of this post it replaces benchmarked Boot 3.4.0 / JDK 21.0.3 with `wrk` (1,000
connections, 30s) on a 4-core/8GB box. Neither `wrk` nor an equivalent load generator is
available in this repo's build environment, and the environment itself is a 2 vCPU sandbox,
not a 4-core server. Rather than copy the old numbers forward with a changed version number
next to them, this repo builds its own concurrent load generator (`java.net.http.HttpClient`
backed by a virtual-thread executor, itself only used as the *client* -- see
`fireConcurrent()`) and reports what actually happened on this run, on this hardware, honestly
labeled as such.
## I/O-bound: `/io`, `Thread.sleep(300)`
600 concurrent requests, each a 300ms simulated downstream call:
```
platform threads : total=600 success=600 wall=1256ms p50=762ms p99=1193ms
virtual threads : total=600 success=600 wall=675ms p50=531ms p99=654ms
```
600 concurrent requests against Tomcat's default 200-thread platform pool queue in roughly
three sequential batches of 200; each batch pays the full 300ms, so total wall time lands
around 3 x 300ms plus scheduling overhead -- which is what the platform-thread row shows.
Virtual threads create one thread per request and unmount for the duration of the sleep, so
wall time stays close to a single 300ms round, regardless of how far past 200 the concurrency
goes. The shape matches the original 2025 benchmark; the absolute numbers are this sandbox's,
not that server's.
## CPU-bound: `/cpu`, 20,000x SHA-256 per request
This is the scenario worth reading carefully, because the first version of this benchmark
was wrong, and the fix is itself worth knowing about.
**First cut, concurrency=16, no warm-up round:** virtual threads finished 4x faster than
platform threads. That result doesn't make physical sense -- a CPU-bound virtual thread never
unmounts, so it competes for the same physical cores a platform thread would, and should show
no structural advantage. The cause: `MessageDigest.digest` and the servlet dispatch path get
JIT-compiled **per class**, not per Spring context, and this benchmark starts a fresh
`ApplicationContext` for each of the two scenarios inside the same JVM. Platform threads always
ran first; by the time the virtual-thread scenario ran, the hot loop was already JIT-warmed --
a benchmarking artifact with nothing to do with thread model, caught by the numbers being
implausible rather than by inspecting the code.
**Fix:** an untimed warm-up round (`fireConcurrent` at a smaller concurrency, discarded)
through the same code path before every timed measurement, in both scenarios.
**After the fix, concurrency=60** (chosen deliberately above this box's 2 vCPUs, so both
thread models are forced into real core-bound saturation rather than fitting comfortably
alongside each other):
```
platform threads : total=60 success=60 wall=166ms p50=146ms p99=162ms
virtual threads : total=60 success=60 wall=191ms p50=181ms p99=189ms
```
Close, with platform threads and virtual threads trading the lead by a few percent across
repeated runs (see the test's own run-to-run notes in
[`LoadBenchmarkTest.java`](../src/test/java/com/ankurm/vthreads/LoadBenchmarkTest.java)) --
consistent with the "no meaningful difference for CPU-bound work" claim every virtual threads
article makes, but arrived at here by running it, catching a real measurement bug, fixing the
methodology, and re-running it, rather than by assuming the claim was self-evidently true.
<blockquote>If your own CPU-bound benchmark shows virtual threads winning decisively, check
your warm-up before you check your thread model. JIT compilation state leaking between two
scenarios measured in the same JVM run is an easy way to manufacture a result that isn't
real.</blockquote>
- If you're benchmarking your own service: match concurrency to something past your real
platform-thread pool size for the I/O case, and past your core count for the CPU case --
otherwise neither test forces the behaviour you're trying to observe.
- JEP 444 (virtual threads, JDK 21): <https://openjdk.org/jeps/444> (`rel=nofollow`)
Next: [03-pinning-diagnosis.md](03-pinning-diagnosis.md).
@@ -1,104 +0,0 @@
# 3. Pinning diagnosis, corrected for JEP 491
[Previous: 02-benchmark-methodology.md](02-benchmark-methodology.md) | [README](../README.md) | Next: [04-scoped-value-and-checklist.md](04-scoped-value-and-checklist.md)
Source: [`PinningDemoService.java`](../src/main/java/com/ankurm/vthreads/PinningDemoService.java), [`PinningTraceCheckMain.java`](../src/main/java/com/ankurm/vthreads/PinningTraceCheckMain.java).
Test: [`PinningJep491Test.java`](../src/test/java/com/ankurm/vthreads/PinningJep491Test.java).
Script: [`scripts/check-trace-pinned-threads-removed.sh`](../scripts/check-trace-pinned-threads-removed.sh).
Transcripts: [`docs/output/03a-pinning-jep491-proof.txt`](output/03a-pinning-jep491-proof.txt), [`docs/output/03b-trace-pinned-threads-removed.txt`](output/03b-trace-pinned-threads-removed.txt).
## The thing every virtual threads article says, that stopped being true in JDK 24
Every pre-JDK-24 article about virtual threads -- including the version of this post it
replaces -- has some version of: "a virtual thread that enters a `synchronized` block and then
blocks inside it cannot unmount; it pins its carrier thread for the whole operation." That was
true through JDK 23. [JEP 491, "Synchronize Virtual Threads without Pinning"](https://openjdk.org/jeps/491)
(`rel=nofollow`) shipped GA in **JDK 24** and changed it: as of JDK 24, ordinary `synchronized`
blocks and methods, and `Object.wait()`, no longer pin. This repo runs JDK 25, so it inherits
that behaviour, and the old advice is now wrong for anyone on JDK 24+.
A stronger version of this same proof already exists in a sibling ankurm.com companion repo:
[`spring-async-demo/async`](https://ankurm.com/git.app/asmhatre/spring-async-demo/src/branch/main/async/docs/08-virtual-threads-and-pinning.md),
for the article [@Async in Spring Boot 4: Executors, Virtual Threads and the Self-Invocation Trap](https://ankurm.com/spring-boot-4-async-executors-virtual-threads/),
runs the **identical class file on two different JDKs** -- 21.0.12.1 and 25.0.4.1 -- and shows
the same guarded run going from 4806ms (matching the pinned prediction almost exactly) to
301ms. This repo only had JDK 25 available to test against, so its own proof below is a
single-JDK version of the same result; if you want the direct before/after on two real JDK
installs, that's the one to read.
## Proving it rather than citing it
The JEP text is the primary source, but the house standard here is a real run, not a citation.
`PinningJep491Test` starts 60 virtual threads, each entering `synchronized` on its own,
**distinct** lock object (so no thread ever contends with another for the lock itself -- any
serialization observed is carrier-thread pinning, not ordinary lock contention), then sleeping
250ms while still holding it:
```
availableProcessors (default virtual-thread carrier pool size) = 2
virtual threads = 60, each holds a DISTINCT monitor for 250ms
predicted wall time IF PINNED (pre-JDK-24 behaviour): ~7500ms
predicted wall time IF NOT PINNED (JDK 24+ behaviour): ~250ms, independent of carrier count
actual wall time: 253ms
verdict: NOT PINNED -- matches JEP 491's documented JDK 24+ behaviour
```
If this ran on JDK 23 or earlier, 60 virtual threads sharing a 2-thread carrier pool while each
pins for 250ms would serialize into 30 sequential batches: roughly 7.5 seconds. It took 253ms.
That's not "a bit better" -- it's the entire pinning cost gone for this pattern.
## The diagnostic flag from every one of those articles no longer does anything
`-Djdk.tracePinnedThreads=full` was the standard way to find pinning: pre-JDK-24, the JVM
printed a stack trace to stdout every time a virtual thread pinned. JEP 491 removed the
property along with most of the pinning it used to report on. `scripts/check-trace-pinned-threads-removed.sh`
runs the same synchronized-then-sleep scenario twice, once with the flag and once without, and
diffs the output:
```
-- without -Djdk.tracePinnedThreads=full --
java.version=25.0.4.1
jdk.tracePinnedThreads=null
Running a virtual thread that holds a monitor across Thread.sleep(200)...
Done. If jdk.tracePinnedThreads still worked on this JDK, a pinned-thread stack trace would have printed above while the virtual thread was inside doWorkHoldingMonitor.
-- with -Djdk.tracePinnedThreads=full --
java.version=25.0.4.1
jdk.tracePinnedThreads=full
Running a virtual thread that holds a monitor across Thread.sleep(200)...
Done. If jdk.tracePinnedThreads still worked on this JDK, a pinned-thread stack trace would have printed above while the virtual thread was inside doWorkHoldingMonitor.
RESULT: no pinned-thread stack trace was printed by either run -- the flag is inert on this JDK, consistent with JEP 491
```
Setting the property has no effect at all on JDK 25 -- not an error, not a deprecation
warning, just silence either way. A team that kept `-Djdk.tracePinnedThreads=full` in their
JVM flags through a JDK upgrade would get zero signal from it going forward and might not
notice.
## What replaces it
JEP 491 kept the `jdk.VirtualThreadPinned` JFR event for the pinning that's still possible --
it now reports both the pinning reason and the carrier thread's identity. The remaining case,
[per the JEP itself](https://openjdk.org/jeps/491): a virtual thread that calls native code
(a native method, or the Foreign Function & Memory API) which itself calls back into Java code
that blocks or synchronizes. That's a narrower trigger than "any `synchronized` plus I/O" and
one this repo does not attempt to reproduce -- constructing a real native-callback pinning
case needs JNI, which is out of scope for a Spring Boot demo. Treat it as: still real, still
worth the JFR event, much rarer in ordinary application code than the blanket old advice
implied.
<blockquote><strong>If you're diagnosing a suspected pinning problem on JDK 24+,</strong>
delete <code>-Djdk.tracePinnedThreads=full</code> from your flags -- it does nothing -- and
capture the <code>jdk.VirtualThreadPinned</code> JFR event instead
(<code>jcmd &lt;pid&gt; JFR.start</code> or a startup <code>-XX:StartFlightRecording</code>).
If your JDBC driver documentation still warns about <code>synchronized</code>-based pinning
from before your driver's JDK 24 testing, verify against your actual JDK version before
believing it.</blockquote>
- JEP 491 in full, including the exact wording on what still pins:
<https://openjdk.org/jeps/491> (`rel=nofollow`)
- JEP 444 (virtual threads, JDK 21 baseline): <https://openjdk.org/jeps/444> (`rel=nofollow`)
Next: [04-scoped-value-and-checklist.md](04-scoped-value-and-checklist.md).
@@ -1,80 +0,0 @@
# 4. ScopedValue, the JDBC driver advice, and a production checklist
[Previous: 03-pinning-diagnosis.md](03-pinning-diagnosis.md) | [README](../README.md)
## ScopedValue is finalized in JDK 25, with a different API than the preview version
The old post's `ThreadLocal` gotcha recommended `ScopedValue` as "the Project Loom-native
replacement," using `ScopedValue.runWhere(CTX, value, () -> { ... })`. Two things changed:
1. **[JEP 506](https://openjdk.org/jeps/506) (`rel=nofollow`) finalized `ScopedValue` in JDK 25** --
it's no longer a preview feature and needs no `--enable-preview` flag. Confirmed by compiling
and running a `ScopedValue` example on this repo's JDK 25 with no preview flags at all.
2. **`ScopedValue.runWhere(...)` does not exist on the finalized API.** `javap -p java.lang.ScopedValue`
against this JDK's runtime classes shows no such method. The finalized shape is
`ScopedValue.where(scopedValue, value).run(runnable)` (a `Carrier` returned by `where`, with
`.run(...)` or `.call(...)` on it):
```java
private static final ScopedValue<ExpensiveContext> CTX = ScopedValue.newInstance();
ScopedValue.where(CTX, new ExpensiveContext()).run(() -> {
// CTX is accessible in this scope and any method called from here
processRequest();
// automatically cleared when the scope exits
});
```
Code copied from a pre-JDK-25 `ScopedValue` article using `runWhere` will not compile on this
JDK. This is a small API surface change, but it's the kind of thing `javap` catches in seconds
and a fabricated-from-memory snippet would not.
## The JDBC driver pinning advice needs the same correction as the general case
The old post's second gotcha was "many JDBC drivers use `synchronized` internally in their
socket I/O paths, so every DB call becomes a pinning event" -- true through JDK 23, and the
reason MySQL Connector/J and older PostgreSQL JDBC driver versions got singled out for their
internal locking. [Chapter 3](03-pinning-diagnosis.md) already established that ordinary
`synchronized`-then-block no longer pins as of JDK 24. That correction applies here without
exception: if a driver's pinning was caused by a plain `synchronized` block around socket I/O
(the common case), **JEP 491 fixes it for free on JDK 24+, with no driver upgrade required.**
The narrower native-callback pinning case from chapter 3 is the only mechanism left that could
still affect a driver, and it needs the driver to call native code that calls back into
blocking Java -- unusual for a JDBC driver's I/O path. Treat "upgrade your JDBC driver to fix
pinning" as obsolete advice on JDK 24+; verify with `jdk.VirtualThreadPinned` JFR events on
your actual driver and JDK version rather than assuming either the old warning or this
correction applies to your exact setup.
## Never pool virtual threads -- unchanged
Nothing about JEP 491 changes this. Virtual threads are cheap to create; pooling them adds
synchronization overhead for no benefit and defeats the design:
```java
// WRONG: pooling virtual threads
ExecutorService pool = Executors.newFixedThreadPool(100, Thread.ofVirtual().factory());
// RIGHT: unbounded virtual-thread-per-task executor
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
```
This repo's own load generator (`LoadBenchmarkTest.fireConcurrent`) uses exactly this pattern
for its `HttpClient` executor.
## Production checklist for Boot 4.1 / JDK 25
- Enable with `spring.threads.virtual.enabled=true` -- one property, unchanged since Boot 3.2.
- Confirm you're actually on JDK 24+ before relying on JEP 491 -- `synchronized` still pins on
JDK 21-23.
- Delete `-Djdk.tracePinnedThreads=full` from your flags on JDK 24+; it's inert. Use the
`jdk.VirtualThreadPinned` JFR event instead.
- Profile I/O-bound endpoints for the real win; don't expect anything from CPU-bound work --
[chapter 2](02-benchmark-methodology.md) measured this directly, including a benchmarking
bug that briefly suggested otherwise.
- If you still see pinning on JDK 24+, suspect native-callback code paths (JNI, Foreign
Function & Memory API), not plain `synchronized`.
- Prefer `ScopedValue` over `ThreadLocal` for new code holding per-request context, using the
finalized JDK 25 API (`ScopedValue.where(...).run(...)`, not the old preview `runWhere`).
- Never pool virtual threads.
- Further reading: [JEP 444](https://openjdk.org/jeps/444) (`rel=nofollow`), [JEP 491](https://openjdk.org/jeps/491) (`rel=nofollow`), [JEP 506](https://openjdk.org/jeps/506) (`rel=nofollow`).
@@ -1,4 +0,0 @@
GET /thread-info with spring.threads.virtual.enabled=false vs true
==================================================================
enabled=false -> Thread: Thread[#9742,http-nio-auto-5-exec-1,5,main] | Virtual: false
enabled=true -> Thread: VirtualThread[#9769,tomcat-handler-0]/runnable@ForkJoinPool-1-worker-1 | Virtual: true
@@ -1,7 +0,0 @@
I/O-bound endpoint (/io, Thread.sleep(300)), concurrency=600, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK 25.0.4.1
==============================================================================================================
platform threads : total=600 success=600 wall=1325ms p50=733ms p99=1201ms
virtual threads : total=600 success=600 wall=1042ms p50=658ms p99=720ms
default embedded Tomcat platform-thread pool = 200; with 600 concurrent 300ms-sleep
requests, platform threads must queue in ~3 sequential batches, virtual threads do not.
@@ -1,8 +0,0 @@
CPU-bound endpoint (/cpu, 20,000x SHA-256), concurrency=60, 2 vCPU sandbox, Spring Boot 4.1.1 / JDK 25.0.4.1
============================================================================================================
platform threads : total=60 success=60 wall=208ms p50=130ms p99=202ms
virtual threads : total=60 success=60 wall=201ms p50=187ms p99=196ms
a CPU-bound virtual thread never yields -- it stays mounted on its carrier for the full
computation, so it competes for the same 2 physical cores a platform thread would. Expect
these two wall times to be close, not virtual threads winning decisively as in the I/O case.
@@ -1,9 +0,0 @@
JEP 491 proof: synchronized held across Thread.sleep, JDK 25.0.4.1
==================================================================
availableProcessors (default virtual-thread carrier pool size) = 2
virtual threads = 60, each holds a DISTINCT monitor for 250ms
predicted wall time IF PINNED (pre-JDK-24 behaviour): ~7500ms
predicted wall time IF NOT PINNED (JDK 24+ behaviour): ~250ms, independent of carrier count
actual wall time: 251ms
verdict: NOT PINNED -- matches JEP 491's documented JDK 24+ behaviour
@@ -1,18 +0,0 @@
java -version:
openjdk version "25.0.4.1" 2026-08-18 LTS
OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS, mixed mode, sharing)
-- without -Djdk.tracePinnedThreads=full --
java.version=25.0.4.1
jdk.tracePinnedThreads=null
Running a virtual thread that holds a monitor across Thread.sleep(200)...
Done. If jdk.tracePinnedThreads still worked on this JDK, a pinned-thread stack trace would have printed above while the virtual thread was inside doWorkHoldingMonitor.
-- with -Djdk.tracePinnedThreads=full --
java.version=25.0.4.1
jdk.tracePinnedThreads=full
Running a virtual thread that holds a monitor across Thread.sleep(200)...
Done. If jdk.tracePinnedThreads still worked on this JDK, a pinned-thread stack trace would have printed above while the virtual thread was inside doWorkHoldingMonitor.
RESULT: no pinned-thread stack trace was printed by either run -- the flag is inert on this JDK, consistent with JEP 491
@@ -1,4 +0,0 @@
ScopedValue (JEP 506, finalized JDK 25), JDK 25.0.4.1
=====================================================
ScopedValue.where(CTX, ctx).call(ScopedValueDemo::processRequest) -> handled req-42
no --enable-preview flag used on this run
-48
View File
@@ -1,48 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>virtual-threads-boot4-demo</artifactId>
<version>1.0.0</version>
<name>virtual-threads-boot4-demo</name>
<description>Virtual threads on Spring Boot 4.1 / JDK 25, re-benchmarked, with a corrected pinning-diagnosis section for JEP 491</description>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -1,31 +0,0 @@
#!/usr/bin/env bash
# Confirms -Djdk.tracePinnedThreads=full is inert on this JDK (JEP 491, GA in JDK 24).
# Pre-JDK-24, this flag made a pinned virtual thread print a stack trace to stdout while
# pinned. Runs PinningTraceCheckMain (which holds a monitor across Thread.sleep(200) on a
# virtual thread) once without the flag and once with it, and diffs stdout. Output:
# docs/output/03b-trace-pinned-threads-removed.txt. See docs/03-pinning-diagnosis.md.
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p docs/output
OUT=docs/output/03b-trace-pinned-threads-removed.txt
echo "java -version:" > "$OUT"
java -version 2>&1 | grep -v "^Picked up JAVA_TOOL_OPTIONS" >> "$OUT"
echo "" >> "$OUT"
echo "-- without -Djdk.tracePinnedThreads=full --" >> "$OUT"
java -cp target/classes com.ankurm.vthreads.PinningTraceCheckMain 2>&1 | grep -v "^Picked up JAVA_TOOL_OPTIONS" >> "$OUT"
echo "" >> "$OUT"
echo "-- with -Djdk.tracePinnedThreads=full --" >> "$OUT"
java -Djdk.tracePinnedThreads=full -cp target/classes com.ankurm.vthreads.PinningTraceCheckMain 2>&1 | grep -v "^Picked up JAVA_TOOL_OPTIONS" >> "$OUT"
echo "" >> "$OUT"
if grep -q "Thread\[#" "$OUT" && grep -qi "monitors:" "$OUT"; then
echo "RESULT: a pinned-thread stack trace WAS printed -- flag still functional on this JDK" >> "$OUT"
else
echo "RESULT: no pinned-thread stack trace was printed by either run -- the flag is inert on this JDK, consistent with JEP 491" >> "$OUT"
fi
cat "$OUT"
@@ -1,12 +0,0 @@
#!/usr/bin/env bash
# Regenerates every file under docs/output/ from a real run: the JUnit test suite (which
# writes 00, 01, 02, 03a via the Transcript helper as it asserts) plus the standalone
# tracePinnedThreads check (03b, which needs its own JVM flag and isn't a JUnit test).
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -q -B test
./scripts/check-trace-pinned-threads-removed.sh > /dev/null
echo "Regenerated:"
ls -1 docs/output/
-13
View File
@@ -1,13 +0,0 @@
#!/usr/bin/env bash
# Starts the app on :8080 to poke at by hand. Pass "vt" to enable virtual threads.
# ./scripts/run.sh -> platform threads (Boot default)
# ./scripts/run.sh vt -> spring.threads.virtual.enabled=true
set -euo pipefail
cd "$(dirname "$0")/.."
VT_FLAG="false"
if [ "${1:-}" = "vt" ]; then
VT_FLAG="true"
fi
mvn -q -B spring-boot:run -Dspring-boot.run.arguments="--spring.threads.virtual.enabled=${VT_FLAG}"
@@ -1,39 +0,0 @@
package com.ankurm.vthreads;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* Two endpoints used for the I/O-bound vs CPU-bound benchmark in
* docs/02-benchmark-methodology.md. /io simulates a blocking downstream call
* (JDBC, HTTP) with Thread.sleep. /cpu simulates real computation with a SHA-256
* loop so it cannot be optimized away and actually burns a core.
*/
@RestController
public class DemoController {
@GetMapping("/io")
public String io() throws InterruptedException {
Thread.sleep(300);
return "io:" + Thread.currentThread();
}
@GetMapping("/cpu")
public String cpu() throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] data = "spring-boot-4.1-virtual-threads-benchmark".getBytes();
for (int i = 0; i < 20_000; i++) {
data = md.digest(data);
}
return "cpu:" + Thread.currentThread() + ":" + data.length;
}
@GetMapping("/thread-info")
public String threadInfo() {
Thread t = Thread.currentThread();
return "Thread: " + t + " | Virtual: " + t.isVirtual();
}
}
@@ -1,33 +0,0 @@
package com.ankurm.vthreads;
/**
* Demonstrates JEP 491 (Synchronize Virtual Threads without Pinning, GA in JDK 24,
* so present in this repo's JDK 25). Before JEP 491, a virtual thread that entered a
* synchronized block or method and then blocked inside it (I/O, Thread.sleep, a
* monitor wait) could not unmount from its carrier thread -- it "pinned" the carrier
* for the entire blocking operation. As of JDK 24, ordinary monitor acquisition and
* blocking inside a synchronized region no longer pins. See docs/03-pinning-diagnosis.md
* for the primary source and the concurrency-based proof this repo runs.
*/
public class PinningDemoService {
/**
* Holds a monitor (via synchronized) across a blocking Thread.sleep call -- the
* textbook "this used to pin" example from every pre-JDK-24 virtual threads
* article, including the version of this post it replaces.
*/
public static synchronized void doWorkHoldingMonitor(long sleepMillis) throws InterruptedException {
Thread.sleep(sleepMillis);
}
/**
* Same operation, but synchronized on a distinct, per-call lock object so that
* concurrent callers never contend with each other for the *lock* itself. Any
* serialization observed is carrier-thread pinning, not lock contention.
*/
public static void doWorkHoldingDistinctMonitor(Object lock, long sleepMillis) throws InterruptedException {
synchronized (lock) {
Thread.sleep(sleepMillis);
}
}
}
@@ -1,27 +0,0 @@
package com.ankurm.vthreads;
/**
* Standalone (no Spring context) check that -Djdk.tracePinnedThreads=full is inert on this
* JDK. Pre-JDK-24, this flag made the JVM print a stack trace to stdout every time a virtual
* thread pinned its carrier. JEP 491 removed the property along with most of the pinning it
* used to report on: "setting it on the command line will have no effect." Run via
* scripts/check-trace-pinned-threads-removed.sh, which invokes this twice (with and without
* the flag) and diffs the output. See docs/03-pinning-diagnosis.md.
*/
public class PinningTraceCheckMain {
public static void main(String[] args) throws Exception {
System.out.println("java.version=" + System.getProperty("java.version"));
System.out.println("jdk.tracePinnedThreads=" + System.getProperty("jdk.tracePinnedThreads"));
System.out.println("Running a virtual thread that holds a monitor across Thread.sleep(200)...");
Thread vt = Thread.ofVirtual().start(() -> {
try {
PinningDemoService.doWorkHoldingMonitor(200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
vt.join();
System.out.println("Done. If jdk.tracePinnedThreads still worked on this JDK, a pinned-thread " +
"stack trace would have printed above while the virtual thread was inside doWorkHoldingMonitor.");
}
}
@@ -1,29 +0,0 @@
package com.ankurm.vthreads;
/**
* Demonstrates the JDK 25 FINALIZED ScopedValue API (JEP 506, GA in JDK 25 -- no
* --enable-preview needed). The old preview-era static shortcut ScopedValue.runWhere(...)
* does not exist on this API; javap -p java.lang.ScopedValue against this JDK's runtime
* classes confirms it. The finalized shape returns a Carrier from where(...), with .run(...)
* or .call(...) on it. See docs/04-scoped-value-and-checklist.md.
*/
public class ScopedValueDemo {
record ExpensiveContext(String requestId) {}
private static final ScopedValue<ExpensiveContext> CTX = ScopedValue.newInstance();
public static String processRequest() {
// CTX is accessible here and in anything this method calls, because it's invoked
// from inside the ScopedValue.where(...).run(...) scope below.
return "handled " + CTX.get().requestId();
}
public static String runWithContext(String requestId) {
return ScopedValue.where(CTX, new ExpensiveContext(requestId))
.call(ScopedValueDemo::processRequest);
// CTX is automatically cleared once this scope exits -- no cleanup needed, and no
// stale value can leak to a later virtual thread the way a forgotten
// ThreadLocal.remove() can.
}
}
@@ -1,11 +0,0 @@
package com.ankurm.vthreads;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class VirtualThreadsBoot4DemoApplication {
public static void main(String[] args) {
SpringApplication.run(VirtualThreadsBoot4DemoApplication.class, args);
}
}
@@ -1,19 +0,0 @@
spring:
application:
name: virtual-threads-boot4-demo
threads:
virtual:
enabled: false
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,metrics
logging:
level:
com.ankurm.vthreads: INFO
@@ -1,164 +0,0 @@
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();
}
}
}
@@ -1,64 +0,0 @@
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);
}
}
@@ -1,27 +0,0 @@
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");
}
}
@@ -1,65 +0,0 @@
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();
}
}
}
@@ -1,50 +0,0 @@
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);
}
}
}