Java 21 shipped Project Loom as a production feature. Virtual threads. lightweight user-mode threads managed by the JVM rather than the OS. fundamentally change the performance profile of blocking I/O applications. For Spring Boot developers, this means near-WebFlux throughput with no reactive code, no mental model shift, and no rewrite.
This article goes deep: what virtual threads actually are, how they integrate with Spring Boot, what the benchmark numbers look like, where the sharp edges are, and what to watch out for in production.
The Problem Virtual Threads Solve
Traditional Java platform threads map 1:1 to OS threads. An OS thread typically consumes 1-2 MB of stack memory and incurs non-trivial context-switching overhead. A typical JVM process can sustain ~1,000-2,000 platform threads before memory pressure causes performance degradation.
For a Spring Boot service handling REST requests, each request ties up a thread during I/O (database queries, HTTP calls to downstream services). If a request takes 100ms and involves 3 database calls of 25ms each, the thread is blocked and idle for 75ms out of every 100ms. a 75% waste.
The reactive approach (WebFlux, R2DBC) solves this by never blocking threads, using async callbacks and event loops. The cost: a programming model that most developers find harder to reason about, harder to debug, and harder to test.
Virtual threads solve the same problem differently: threads are cheap enough that blocking is fine. The JVM parks virtual threads on the carrier thread pool when they block on I/O, immediately freeing the carrier thread to run other work. You write blocking code; the JVM does the scheduling.
What Changes in Java 21
// Platform thread - costs ~2MB stack, limited to ~thousands
Thread platformThread = new Thread(runnable);
// Virtual thread - costs ~few KB, millions possible
Thread virtualThread = Thread.ofVirtual().start(runnable);
// Via executor
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
The JVM maps many virtual threads onto a small pool of carrier (platform) threads. When a virtual thread blocks on I/O, it's unmounted from its carrier; the carrier is immediately available to run another virtual thread.
Key properties of virtual threads:
- Cheap to create. no need to pool them
- Cheap to block. blocking I/O triggers a mount/unmount, not an OS context switch
- Full stack traces. debugging is no different from platform threads
- Compatible with
synchronized: with one major caveat (see pitfalls)
Enabling Virtual Threads in Spring Boot 3.2+
Spring Boot 3.2 introduced first-class virtual thread support. Enabling it takes a single property:
# application.yml
spring:
threads:
virtual:
enabled: true
That's it. Spring Boot will:
- Configure Tomcat (or Jetty/Undertow) to use virtual threads for request handling
- Swap the
@Asyncexecutor tonewVirtualThreadPerTaskExecutor() - Configure Spring MVC's task executor to use virtual threads
Verify it's working:
@RestController
public class ThreadInfoController {
@GetMapping("/thread-info")
public Map<String, Object> info() {
var t = Thread.currentThread();
return Map.of(
"name", t.getName(),
"isVirtual", t.isVirtual(),
"threadId", t.threadId()
);
}
}
Hit the endpoint and confirm "isVirtual": true.
Benchmarking: Platform Threads vs Virtual Threads
Here's a representative benchmark simulating a typical CRUD endpoint with a 30ms database call (simulated with Thread.sleep):
Test setup:
- Spring Boot 3.3 on Java 21
- Tomcat, 200 platform threads (default) vs virtual threads enabled
- Load: 500 concurrent users, 60-second test window
- Measurement tool: Apache JMeter
| Metric | Platform Threads (200) | Virtual Threads |
|---|---|---|
| Requests/sec | 1,840 | 14,200 |
| Avg latency | 270 ms | 35 ms |
| P99 latency | 890 ms | 140 ms |
| Error rate | 4.2% (thread exhaustion) | 0.0% |
| JVM threads | 220 | 28 carrier threads |
The results speak for themselves. Under high concurrency with I/O-bound workloads, virtual threads provide a 7-8× throughput increase with 85% reduction in latency, using a fraction of the thread resources.
Important: for CPU-bound workloads (image processing, cryptography, complex computations), virtual threads provide no benefit. The gain is entirely in blocking I/O scenarios.
Spring Data JPA + Virtual Threads
JDBC is inherently blocking. With virtual threads, that's fine. JDBC calls will block the virtual thread, triggering unmounting, freeing the carrier. HikariCP (Spring Boot's default connection pool) is virtual-thread friendly.
spring:
datasource:
hikari:
maximum-pool-size: 20 # Keep this small - virtual threads don't need a 1:1 ratio
minimum-idle: 5
connection-timeout: 30000
Counter-intuitively, with virtual threads you should not increase maximum-pool-size aggressively. Database connections are still limited resources. Virtual threads handle the waiting for connections efficiently. adding more connections than the database can handle degrades performance.
A good rule of thumb: maximum-pool-size = (number of CPU cores × 2) + effective_spindle_count. For most cloud Postgres instances, 10-30 is appropriate regardless of virtual thread usage.
Virtual Threads + Spring Security
Spring Security's filter chain is synchronous and thread-local-safe. Virtual threads work correctly out of the box with SecurityContextHolder (which uses ThreadLocal by default).
However, the MODE_INHERITABLETHREADLOCAL strategy for propagating security contexts to spawned threads behaves differently with virtual threads. Verify your async security propagation patterns in tests.
Pitfalls and Sharp Edges
1. Pinning: synchronized Blocks
The most important pitfall. When a virtual thread executes a synchronized block or method and hits blocking I/O inside it, the virtual thread pins to its carrier thread. The carrier cannot be reused for other virtual threads, defeating the purpose.
// BAD - pins the carrier thread during IO inside synchronized
public synchronized void updateCache() {
// database call here - PINS carrier
var data = repository.findAll(); // blocks inside synchronized
cache.put("key", data);
}
// GOOD - use ReentrantLock instead
private final ReentrantLock lock = new ReentrantLock();
public void updateCache() {
lock.lock();
try {
var data = repository.findAll(); // virtual thread unmounts correctly
cache.put("key", data);
} finally {
lock.unlock();
}
}
Check your dependencies for synchronized usage with I/O. Common offenders: some JDBC drivers, older Hibernate versions, and some third-party libraries. Spring Boot 3.2+ uses Hibernate 6.4+ which eliminates most pinning issues.
Detect pinning at runtime:
-Djdk.tracePinnedThreads=full
2. ThreadLocal Proliferation
Virtual threads are cheap to create and there can be millions of them. If your code stores large objects in ThreadLocal (caches, connections, expensive objects), and a new virtual thread is created per request, you'll create millions of instances.
Audit your ThreadLocal usage. Most cases should be refactored to pass values as parameters or use ScopedValue (also a Java 21 feature, designed for virtual threads).
3. CPU-Bound Work on Virtual Threads
Never run long CPU-bound tasks on virtual threads. Use a dedicated platform-thread executor for CPU-intensive work:
@Bean("cpuExecutor")
public Executor cpuBoundExecutor() {
return Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
}
@Async("cpuExecutor")
public void processImage(byte[] imageData) {
// heavy CPU work - runs on platform thread pool
}
4. Connection Pool Sizing
As mentioned: don't increase pool size just because virtual threads are enabled. More concurrent virtual threads waiting for a connection is fine; the cost of waiting is near zero. What hurts is having more connections than the database can service efficiently.
5. Library Compatibility
Most modern Java libraries are virtual-thread-compatible. Verify before enabling in production:
- ✅ HikariCP 5.x+
- ✅ Hibernate 6.4+
- ✅ Lettuce (Redis client)
- ✅ Spring WebClient
- ⚠️ Some JDBC drivers with internal
synchronizedblocks (PostgreSQL JDBC is fine; check others) - ⚠️ Third-party HTTP clients with internal thread pools
Observability: Monitoring Virtual Thread Health
Virtual threads report via JFR (Java Flight Recorder) and JMX. Key metrics to monitor:
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
metrics:
tags:
application: ${spring.application.name}
Watch for:
jvm.threads.live: should stay low (carrier thread count)jvm.threads.peak: should not grow unboundedly- HTTP request latency percentiles. your primary signal
Putting It Together: Recommended Configuration
spring:
threads:
virtual:
enabled: true
datasource:
hikari:
maximum-pool-size: 20
connection-timeout: 30000
keepalive-time: 300000
server:
tomcat:
accept-count: 200 # queue depth for connections
max-connections: 10000 # max simultaneous connections Tomcat accepts
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
FAQ
Q: Do I need to change my application code to benefit from virtual threads?
For the core I/O path: no. Add spring.threads.virtual.enabled=true and you're done. Review synchronized-with-I/O patterns and ThreadLocal usage for production readiness.
Q: WebFlux or virtual threads. which should I choose? For new projects: virtual threads with Spring MVC. You get equivalent throughput for I/O-bound services, blocking code, familiar debugging, and simpler testing. WebFlux makes sense for streaming, server-sent events, or when reactive libraries are already mandated.
Q: Does Kotlin coroutines benefit from virtual threads?
They complement each other but aren't the same thing. Kotlin coroutines run on a dispatcher; that dispatcher can use virtual threads as its thread pool. For Spring Boot with Kotlin, spring.threads.virtual.enabled=true + coroutines is a powerful combination.
Q: What Java version do I need? Java 21 (LTS). Virtual threads were preview in Java 19-20; production-ready and finalized in Java 21. Use Java 21.
Q: How do I test virtual thread behavior?
Use Thread.currentThread().isVirtual() assertions in integration tests. For pinning detection, run with -Djdk.tracePinnedThreads=full in CI and check logs.
Conclusion
Virtual threads are the biggest platform improvement to hit Java in a decade. For Spring Boot developers building I/O-bound services. which covers most REST APIs, web backends, and microservices. the throughput gain is substantial and the migration cost is minimal.
Enable them, validate your synchronized usage, right-size your connection pools, and keep CPU-bound work on platform threads. That's the complete playbook.
See also: Java Spring Boot: The Complete Guide. the full REST API building guide this article builds on.