Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

11 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Spring Boot 4.1 Java 25 Virtual Threads JDK 25 PostgreSQL 17 MIT License

Spring Boot Virtual Threads

One endpoint: GET /work, a single pooled call against a pinned 10-connection HikariCP pool. It runs under three wirings: the Boot default worker pool (platform), virtual threads with no admission control (vt), and virtual threads behind an explicit @ConcurrencyLimit gate (vt-gated).
All three take the identical closed-loop load spike. The difference between them is something you can read off a CSV rather than take on faith.

Read the companion blog post →


The problem

The usual story about Spring Boot's virtual threads is that swapping platform for vt trades a pool that politely queues (or rejects) excess work for one that "just handles everything." "Platform rejects fast at the door" is folklore. Tomcat's embedded connector queues everything below max-connections (8,192) plus acceptCount (100). It isn't rejecting, it's queueing, and under platform that queue stays invisible because server.tomcat.threads.max (200) accidentally bounds how many requests are inside the app at once. That keeps Hikari waiters low and hides the queueing latency in the connector.

Flip on spring.threads.virtual.enabled=true and nothing about the endpoint changes. The same pinned 10-connection HikariCP pool with a 30 s checkout timeout is still there. What changes is that vt admits everything the connector will take, so the queue that was invisible under platform piles up somewhere else: on the Hikari pool. Goodput barely moves. The queue moved, it did not disappear, and late SQLTransientConnectionExceptions start surfacing as HTTP 500s clustered near the 30 s timeout line. The only thing in this repo that rejects fast is the deliberate vt-gated wiring: an explicit @ConcurrencyLimit(limit = 10, policy = REJECT) gate on WorkService.

This repo runs all three wirings against the same endpoint, the same Hikari pool, and the same closed-loop load spike, so that story is something you can check against a CSV instead of taking on faith.

Architecture

                     load/Spike.java
              (7,000 virtual-thread workers,
                closed loop: hold N in flight)
                          │
                          │  GET /work
                          ▼
                Tomcat 11 (embedded connector)
                          │
        ┌─────────────────┼──────────────────────┐
        ▼                 ▼                       ▼
    platform             vt                   vt-gated
  Boot worker pool   virtual threads      virtual threads +
  (threads.max=200)  (no admission        @ConcurrencyLimit
                       control)            (limit=10, REJECT)
        │                 │                       │
        └─────────────────┼───────────────────────┘
                          ▼
              HikariCP (10 connections,
                30 s connection-timeout)
                          │
                          ▼
                  Postgres 17 (pg_sleep 0.05)

Every wiring hits the same WorkService.doWork(): one pg_sleep(0.05) call through the same 10-connection Hikari pool. platform, vt, and vt-gated differ only in src/main/resources/application-<profile>.yml plus, for vt-gated, the always-present @ConcurrencyLimit annotation on WorkService and its profile-gated GatingConfig (@EnableResilientMethods). Nothing about the SQL, the pool size, or the timeout changes between runs. The pinned bottleneck is deliberate and never touched.

Quick start

You need Docker and mise. mise provisions Java, Maven, and Python automatically, so there's nothing else to install.

git clone https://github.com/lukas-grigis/spring-virtual-threads.git
cd spring-virtual-threads
mise run demo

mise run demo builds the app, starts Postgres (first run pulls the image, so give it a minute), then runs platform, vt, and vt-gated through the same closed-loop load spike in turn, followed by a supplementary vt ceiling run at 9,500 workers. It writes CSVs, probe CSVs, a JFR recording, and app logs into load/results/ and .logs/ as it goes, then renders the figures. Ctrl+C stops it.

Port What's there
:8080/work the app
:8081/actuator management (health, metrics)

Claims → evidence

# Claim Evidence
1 Same endpoint, same pinned Hikari bottleneck (10 connections / 30 s timeout) under all three wirings. platform's threads.max=200 accidentally bounds how many requests can be inside the app at once, which keeps Hikari waiters low and hides the queueing latency in the Tomcat connector: zero errors. Bare vt admits everything the connector will take, so the queue that was invisible under platform piles up on the Hikari pool instead: ~700× the pool size, and late SQLTransientConnectionExceptions surface as HTTP 500s clustered near the 30 s connection-timeout line. Goodput is roughly unchanged between the two; the queue moved, it did not disappear. "Platform rejects fast at the door" is folklore: Tomcat queues everything below max-connections (8192) + acceptCount (100); nothing in this repo rejects fast except the deliberate vt-gated gate. load/results/platform.png, load/results/vt.png, load/results/combined.png
2 Under spring.threads.virtual.enabled=true, server.tomcat.threads.max goes inert (Boot's own docs say so: "Doesn't have an effect if virtual threads are enabled"), and the remaining concurrency gate is server.tomcat.max-connections (default 8192). Both halves of this claim are observed, not asserted: inertness, from the in-flight probe reading ≈200 under platform vs ≈7,000 under vt at the same offered load; the ceiling, from a supplementary run that offers 9,500 workers to the vt profile and watches tomcat.connections.current climb to and plateau at exactly 8,192. load/results/probe.png
3 @ConcurrencyLimit(limit = 10, policy = REJECT) on WorkService restores a deliberate, fast-failing admission gate under vt-gated: a dense 503 band at low-single-digit-to-tens-of-ms latency, successes essentially unaffected, goodput in the same order of magnitude as the other two profiles. Three gotchas, all load-bearing: the default policy is BLOCK (parks the caller indefinitely; there is no timeout knob), the annotation is silently ignored without @EnableResilientMethods (Boot explicitly declined to auto-configure this; spring-boot issue #46916), and an unhandled InvocationRejectedException surfaces as HTTP 500, not 503; the 503 comes from ApiErrorHandler, a handler this repo writes explicitly. The gate is code, not configuration: application-vt.yml and application-vt-gated.yml are byte-for-byte identical apart from comments; the actual diff is the always-present @ConcurrencyLimit annotation on WorkService, the profile-gated GatingConfig (@EnableResilientMethods), and ApiErrorHandler. load/results/vt-gated.png, src/main/java/dev/lukasgrigis/virtualthreads/WorkService.java, src/main/java/dev/lukasgrigis/virtualthreads/GatingConfig.java, src/main/java/dev/lukasgrigis/virtualthreads/ApiErrorHandler.java
4 Residual virtual-thread pinning is caught by the jdk.VirtualThreadPinned JFR event, which carries rich diagnostic fields (pinnedReason, carrierThread, blockingOperation); jdk.tracePinnedThreads is silently inert on JDK 24+: it prints nothing, and is not merely "deprecated". JEP 491 (JDK 24) removed the classic pinning causes: a virtual thread sleeping/waiting inside synchronized no longer pins at all, which is why the demo has to resort to a deliberately contrived case: blocking inside a class static initializer, one of JEP 491's documented remaining cases. This is tied to the real app, not just the toy: the vt evidence run was recorded with -XX:StartFlightRecording, and its jdk.VirtualThreadPinned event count (0, as expected under JEP 491) is reported in load/results/SUMMARY.txt. jfr/PinDemo.java, mise run pindemo output (below), load/results/SUMMARY.txt

mise run pindemo fires exactly one jdk.VirtualThreadPinned event, with:

blockingOperation = "LockSupport.park"
pinnedReason      = "VM call to PinDemo$Blocker.<clinit> on stack"
carrierThread     = "ForkJoinPool-1-worker-N"
eventThread       = "pinned-vt"
duration          ≈ 100 ms

Methodology

Driver. load/Spike.java is a deterministic, single-file Java 25 program (run via the source-file launcher, no build step) that closed-loop-ramps a fixed number of concurrent workers (one virtual thread and one kept-alive HTTP/1.1 connection each) against /work, holds, and writes one row per attempt (ts,status,latency_ms) plus a 1 Hz probe CSV (ts,inflight,connections) sampled from the actuator endpoints on the management port. There is no randomness anywhere in the driver (fixed ramp schedule instead of a seed): every worker's start offset is a deterministic function of its index.

Why 7,000 workers. The Hikari pool (10 connections, 30 s connection-timeout) is the article's pinned subject and is never touched. At ~55 ms average checkout time (the 50 ms pg_sleep plus overhead), the pool's steady-state throughput is ≈ 10 / 0.055 ≈ 182 req/s, so the deepest waiter queue the 30 s timeout can sustain without firing is ≈ 182 × 30 ≈ 5,450. 7,000 is chosen to sit above that (so the timeout genuinely fires under vt) and below server.tomcat.max-connections (8,192), so the connector admits everything offered and any failure that occurs is cleanly Hikari's, not the connector's.

Why 9,500 for the supplementary ceiling run. max-connections (8,192) + acceptCount (100) = 8,292; 9,500 exceeds that by enough margin that the connector ceiling is actually touched and plateaus, rather than merely approached.

Timing. 20 s ramp, 90 s hold (issue window ends at 110 s), 2 s fixed backoff after any non-200 result (keeps the vt-gated CSV, which retries constantly, bounded in size). The 90 s per-request timeout is derived, not tuned: it must exceed max(Hikari's 30 s timeout, worst honest queue wait ≈ workers / goodput ≈ 7,000 / 180 ≈ 39 s) so that the CSV always records the server's verdict (a real status code or Hikari's timeout) and essentially never the client's impatience.

Warmup and steady state. Rows with ts < 25,000 (ramp + 5 s) are excluded from every statistic and drawn at reduced opacity in the figures, with a dashed vertical line marking the cutoff. The post-warmup window still contains platform's ~30 s queue-build transient (workers issued early in the ramp are still climbing toward their first steady-state latency after ts = 25,000), so the figures also report a second, steady-state success p50/p95 computed over ts ≥ 60,000 only.

Sentinel statuses. The driver distinguishes three non-HTTP outcomes: -1 (response timeout, a HttpTimeoutException other than a connect timeout), -2 (any other IOException, a transport error), -3 (HttpConnectTimeoutException, a connect timeout, checked first because it subclasses HttpTimeoutException; signals connector saturation, not a slow request). Probe samples that fail to fetch or parse are written as -1 and plotted as line gaps, never as data points.

503-count caveat. Under vt-gated, the 503 count in the figure is a function of the driver's 2 s retry backoff, not of server behavior alone: every rejection restarts a worker's 2 s wait-then-retry loop, so a worker that spends the whole hold window being rejected racks up dozens of 503 rows. Per-attempt success rate is therefore not goodput; see the deviation below (4.8% per-attempt success vs. 172 req/s goodput, in the same order of magnitude as the other two profiles).

Single-machine caveat. Client and server run on the same laptop, sharing CPU, network stack, and OS scheduler. Absolute latency numbers are indicative, not a production benchmark; the status-mix shapes and the relative comparison between profiles are the evidence.

Per-run summaries land in load/results/SUMMARY.txt (one line per run: total rows and a status:count breakdown; no dates).

Versions (pinned, verified against Maven Central / live docs): Java 25 (OpenJDK 25.0.2), Maven 3.9.12, Spring Boot 4.1.0 (spring-boot-starter-webmvc; manages Spring Framework 7.0.8, Tomcat 11.0.22, HikariCP 7.0.2, Micrometer 1.17.0, postgresql 42.7.11), Postgres 17, matplotlib 3.11.1.

Deviation policy. Runs are committed exactly as they came out. Deviations from the predicted shape are reported below, in this README, never re-rolled, filtered, or parameter-tuned until they "look right."

Deviations from the predicted shape

Before running the spikes, the expected shape of each result was sketched from the queueing math above (pool size, checkout time, timeout, connector ceiling), purely as a sanity check, never as a target to tune toward. The following observed results depart from those predictions; they are reported here rather than smoothed over, per the deviation policy above.

  • vt-gated in-flight/connections read low, not ≈7,000. The prediction was that tomcat.connections.current under vt-gated would sit near ≈7,000 (all offered connections held open) while http.inflight stayed low (the gate bounding in-app concurrency). In-flight came in as predicted (≈10, max 14). Connections did not: the probe reads mostly 11 (a few samples reach 12 to 16), spiking briefly to a maximum of ~24, nowhere near 7,000. This is genuine data, faithfully plotted (the green vt-gated lines in probe.png sit near zero, not near the top of the chart), not a plotting defect: fast rejections (single-digit-to-tens of ms) plus the driver's 2 s post-rejection backoff mean each worker's HTTP/1.1 keep-alive connection has time to go idle and get reclaimed between attempts, so at any given 1 Hz sample only the workers that happen to be mid-request are counted as connected. The README states this explicitly rather than the ≈7,000 figure the original prediction used.
  • vt-ceiling's connect-timeout count is far larger than the "surplus workers" framing suggests, and no response timeouts occurred. SUMMARY.txt records vt-ceiling: ... -3:13022, an order of magnitude above the ~1,200 to 1,300 surplus workers (9,500 offered minus the 8,292 connector capacity) the sizing rationale anticipated, because connect-timeout sentinels accumulate across each surplus worker's retry loop over the whole hold window, not once per worker. The prediction also anticipated a mixed -3/-1 band for the surplus; in the actual run, -1 (response timeout) never occurred (-1:0); all surplus attempts surfaced as -3 (connect timeout). The connections plateau itself, the actual evidence for claim 2's ceiling half, is clean: tomcat.connections.current climbs to and holds exactly at 8,192.
  • vt-gated goodput is somewhat below the other two profiles, not "≈ platform's." Observed goodput: platform 185 req/s, vt 185 req/s, vt-gated 172 req/s. vt-gated is roughly 7% lower than the other two. Still the same order of magnitude, not a cliff, but not the tight match the prediction implied.
  • platform recorded zero tail client timeouts, where the prediction allowed for "a few -1s in the tail." Observed: -1:0. Cleaner than predicted; reported for completeness, not adjusted for.
  • vt-gated's per-attempt success rate is extremely low (4.8%, n post-warmup = 307,234). This is not a goodput problem. With the gate rejecting most attempts in single-digit-to-tens of ms and the driver retrying every 2 s regardless of outcome, each worker fires many more attempts per second than a successful worker would, so the vast majority of attempts are rejections even though goodput (successful requests per second, 172) is in the same order of magnitude as the other profiles. This is the 503-count caveat above, restated with the actual number so the 4.8% is not misread as "the gate barely works"; it is the retry backoff amplifying the rejection count, not the server failing to serve real traffic.
  • vt's successful requests are dominated by a timeout-adjacent band, with a real scattered minority; the "widely scattered" prediction is only partially borne out. Because Hikari's connection bag is deliberately unfair, the prediction called for successes "scattered from fast to ~30 s," alongside the dense 500 band. The actual post-warmup success distribution (15,758 successes) has three populations: a small fast tail of 292 (~1.9%) under 100 ms (workers lucky enough to get a connection immediately); a dominant band of ~89% (14,053) packed between ~29.8 s and ~30.3 s, essentially on top of the dense HTTP 500 band at the same latency; and a genuinely scattered middle population of ~9.0% (1,413) spread across the interval from 100 ms to 29.8 s. Visually the dominant cluster hugs the Hikari connection-timeout line in vt.png, but the scattered minority is real and visible. The dense-500-band half of the prediction holds; the "widely scattered successes" half is partially borne out: the spread across the interval exists, but as a ~9% minority rather than the dominant shape, with most successes landing either immediately or close to the timeout.

Reproduce it

mise run demo

is the one-button path (see Quick start). What follows is the same run broken into its granular steps, useful for driving one profile at a time or watching the app live.

Preconditions: Docker daemon up, and host port 5432 free. lsof -nP -iTCP:5432 -sTCP:LISTEN should print nothing (stop any local Postgres holding the standard port before mise run infra:up).

mise run infra:up      # start Postgres, wait for healthy
mise run build          # package the app; compile-check load/Spike.java and jfr/PinDemo.java
mise run app             # SPRING_PROFILES_ACTIVE=<platform|vt|vt-gated> java -jar target/spring-virtual-threads.jar

While the app is running, in another terminal:

PROFILE=<platform|vt|vt-gated> mise run spike   # writes load/results/<PROFILE>.csv + -probe.csv
mise run check                                    # health, http.inflight, tomcat.connections.current
mise run plot                                     # renders the PNGs from load/results/*.csv
mise run pindemo                                  # standalone jdk.VirtualThreadPinned reproducer

Probe reads (available on the management port while the app is under load):

curl -s localhost:8081/actuator/metrics/http.inflight
curl -s localhost:8081/actuator/metrics/tomcat.connections.current

The committed load/results/ is the reference run the Claims → evidence table and figures above point to. mise run demo regenerates it end to end on your own machine (including the supplementary vt-profile, 9,500-worker ceiling run). Absolute latency and throughput numbers will vary with your hardware, but the status-mix shapes and the relative comparison between profiles are what reproduce; see the single-machine caveat in Methodology.

Tests

mise run test

Docker-free: context tests (the arming invariant, one per profile), gating-semantics tests, the error-handler test, and the in-flight filter test. All are offline JUnit against the app's beans, no Postgres required.

Project structure

.
├── mise.toml                    tool pins + tasks (build/test/infra/app/spike/plot/pindemo/check/demo)
├── pom.xml                      Boot 4.1.0 app (spring-boot-starter-webmvc/jdbc/actuator)
├── support/
│   └── docker-compose.yml       Postgres 17, host port 5432
├── src/main/java/dev/lukasgrigis/virtualthreads/
│   ├── VirtualThreadsApplication.java
│   ├── WorkController.java
│   ├── WorkService.java
│   ├── GatingConfig.java
│   ├── ApiErrorHandler.java
│   └── InFlightFilter.java
├── src/main/resources/          application.yml + one profile yml per wiring (platform/vt/vt-gated)
├── src/test/java/…              context tests, gating semantics, error handler, in-flight filter (all Docker-free)
├── load/
│   ├── Spike.java                the load driver (source-file launcher, no build step)
│   ├── plot.py                   matplotlib figures from load/results/*.csv
│   └── results/                  committed CSVs, probe CSVs, SUMMARY.txt, and the five PNGs
├── jfr/
│   └── PinDemo.java              standalone jdk.VirtualThreadPinned reproducer
├── .logs/                        runtime app logs from `mise run demo` (gitignored)
└── README.md                     this file

Tech stack

Layer What's running
Language / build Java 25 (OpenJDK 25.0.2), Maven 3.9.12
App Spring Boot 4.1.0 (spring-boot-starter-webmvc, -jdbc, -actuator)
Web server Tomcat 11.0.22 (embedded)
Connection pool HikariCP 7.0.2
Database Postgres 17 (Docker Compose)
Metrics Micrometer 1.17.0
JDBC driver postgresql 42.7.11
Figures matplotlib 3.11.1
Tasks / toolchain mise (Java, Maven, Python pinned in mise.toml)

License

MIT

About

Spring Boot virtual threads under load, measured: platform vs vt vs vt-gated on a pinned HikariCP pool

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages