ALGONIQUE RESEARCH LAB · JAVA & BACKEND

Build a fair experiment instead of believing benchmark screenshots.

Download executable research project

Includes source code and this article. Review the environment prerequisites before running.

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.js

Repeat 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 λ\lambda and average time in system is WW, Little’s law gives:

L=λW, L=\lambda W,

where LL 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 LL 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.

References

TAKE IT FURTHER

Can you reason through the underlying concepts?

Use Algonique to explore your technical strengths and learning gaps, not just memorize a code example.

Explore the Java & Backend assessment More technical guides