Java Virtual Threads vs Platform Threads: A Reproducible Spring Boot Benchmark
Category: Java & Backend · Level: intermediate/advanced · Prerequisites: Java 21+, Spring Boot, HTTP basics · Experiment: runnable benchmark harness; no invented measurements
Virtual threads sound like a silver bullet: keep simple blocking Java code, launch vastly more concurrent tasks, and let the JDK manage the threads. But will a production Spring Boot service actually handle more traffic? The answer depends on what each request is waiting for, the version of Java, and the limits imposed by your downstream systems.
This tutorial builds the same Spring Boot endpoint under platform-thread and virtual-thread execution. We show how to generate load with k6, record request failure and latency, and design a fair comparison. The code is included as a minimal Maven project. We intentionally do not publish fabricated throughput numbers; readers can reproduce measurements on their own machines.
1. What problem do virtual threads solve?
A traditional server associates a request with a platform thread. When it waits for a network response, that thread may remain unavailable to do useful work. If thousands of concurrent requests spend most of their time waiting, platform-thread count, memory and scheduler overhead can become limiting factors.
A Java virtual thread is a lightweight Thread scheduled
by the JVM onto a smaller set of carrier platform threads. Many virtual
threads can be parked during suitable blocking operations, letting their
carriers execute other work. Java 21 finalized virtual threads via JEP
444. This improves scalability for blocking, I/O-heavy
workloads; it does not make CPU instructions execute faster.
The JDK still needs CPU cores, and the application still needs database connections, HTTP connection pools, file descriptors and other bounded resources. Replacing one bottleneck can expose another.
2. A fair experiment has two workloads
I/O-bound wait: an endpoint sleeps for 100 ms as a controlled stand-in for a blocked dependency. This isolates thread scheduling, but it is not evidence that a real database or remote service will behave identically. CPU-bound work: a second endpoint performs deterministic arithmetic. Virtual threads should not be expected to improve CPU throughput beyond the physical compute budget.
Create both endpoints in the same Spring Boot application:
@GetMapping("/io")
public String io(@RequestParam(defaultValue = "100") long ms)
throws InterruptedException {
Thread.sleep(ms);
return Thread.currentThread().isVirtual() ? "virtual" : "platform";
}
@GetMapping("/cpu")
public long cpu() {
long x = 0;
for (int i = 1; i <= 100000; i++) x = (x * 31 + i) & Long.MAX_VALUE;
return x;
}The project uses the same executable for both configurations. Spring
Boot exposes spring.threads.virtual.enabled to switch the
request execution model. Java 21+ is required. Java 24+ is
recommended because JEP 491 changed the handling of
synchronized pinning; comparing on Java 21 and generalizing
to Java 24 is misleading.
3. Run it twice, changing one variable
Requires JDK, Maven and k6 installed. The companion project specifies Spring Boot 3.4.5 as a reproducible Maven dependency version. In separate terminal windows:
# Configuration A: platform threads
VIRTUAL_THREADS=false mvn spring-boot:run
curl 'http://localhost:8080/io?ms=100'
VUS=100 DURATION=30s k6 run k6.js
# Stop the server before switching configurations.
# Configuration B: virtual threads
VIRTUAL_THREADS=true mvn spring-boot:run
curl 'http://localhost:8080/io?ms=100'
VUS=100 DURATION=30s k6 run k6.jsRepeat at multiple concurrency levels (e.g. 50, 100, 500, 1000) and
rerun with MODE=cpu for the CPU endpoint. Measure on an
otherwise idle machine or controlled container, with a separate
load-generator host where possible. Confirm the response says
virtual when expected; a configuration flag that never
reaches the request executor makes the comparison meaningless.
4. Which metrics matter?
Throughput: successful completed requests per second, not attempted requests. Latency: median, p95 and p99 response times; long tails matter under overload. Error rate: failed requests, timeouts and 5xx responses must not vanish from a “success” average. Resources: process CPU, heap, native memory and JVM thread state. Capacity: detect the load level where latency rises sharply and failures begin.
A reliable experiment controls JDK version, JVM arguments, heap limits, available CPUs, operating system, dependency latency, warmup duration, test duration, connection reuse, thread pools and logging. Run each condition several times. Record dispersion and uncertainty; a single screenshot is not reproducible evidence.
5. Why response time changes under queuing
If requests arrive at rate and average time in system is , Little’s law gives:
where is the average number of requests in the system under steady-state assumptions. A request that waits 100 ms can yield substantial concurrency without demanding 100 ms of CPU. Virtual threads may let a higher be serviced with less platform-thread overhead, provided the bottleneck is waiting rather than a database pool of ten connections.
Little’s law alone does not predict virtual-thread speedup. It also does not hold naively when you ignore rejected work, uncontrolled overload or an unstable queue. Treat it as a capacity-model sanity check, not a marketing formula.
6. The production gotchas
Database connection pools. If your pool allows 10 active connections, 10,000 virtual threads cannot all query the database simultaneously. More threads may simply create a longer waiting line.
Pinning and JDK differences. On older JDKs, blocking inside certain synchronized/native paths could pin a carrier. JDK 24 changed most monitor-related pinning through JEP 491, but native and other limitations remain. Inspect JFR events and documentation for the exact runtime you deploy.
ThreadLocal memory. Each request may have its own
ThreadLocal state. Code that assumes a small, recycled set
of worker threads may allocate much more per-request state under virtual
threads; expensive thread-local caches can become counterproductive.
Bounded concurrency. Virtual threads are not permission to launch unbounded downstream calls. Rate limiting, semaphores, connection pools and backpressure remain necessary.
Scheduled tasks and daemon semantics. Spring Boot
notes that virtual threads are daemon threads; use
spring.main.keep-alive=true when the application must
remain alive because of scheduled work.
7. Avoid misleading comparisons
Never compare an optimized virtual-thread endpoint to a platform-thread endpoint with extra logging or a smaller pool. Do not test at only one concurrency level. Do not claim platform threads are always inferior: for CPU-bound execution with few concurrent tasks, virtual threads offer little benefit and may add small overheads. Also distinguish platform threads from asynchronous/nonblocking IO architectures; they solve some scalability problems differently.
If you switch to a real downstream dependency, record its p95 latency, pool utilization and rejection counts along with your HTTP metrics. If performance does not improve, determine which limit is saturated before changing thread counts.
8. A complete interpretation checklist
For each test run, record: JDK distribution/version, Spring Boot
version, server profile, VIRTUAL_THREADS flag, CPU quota,
heap size, number of k6 virtual users, load-generator location, warmup
time, steady-state duration, success throughput, p50/p95/p99 latency,
error rate and observed bottleneck. Explain the workload used and attach
raw logs. Be careful not to draw conclusions from a run with zero
failures at a trivial load.
Takeaway: virtual threads are a scalability tool for blocking concurrency, not a CPU acceleration technique. The strongest engineering outcome is a traceable explanation of when they help and what stops them from helping. Continue with Java Concurrency Interview Topics and test your reasoning using the Algonique Java Skill Assessment.
Frequently asked questions
Are virtual threads faster than platform threads? Not necessarily per task. Their main value is supporting high concurrency with blocking code more efficiently.
Do virtual threads remove connection-pool bottlenecks? No. A bounded downstream resource remains bounded.
Do I need Java 24? Java 21 finalized virtual threads. Spring recommends Java 24+ for the best behavior, particularly around changes to pinning.
What is the best benchmark? One reproducing your real workload and latency distribution, with controlled environments and measured success, p95/p99, errors and resources.