Virtual threads landed in Java 21 as a production feature, and Spring Boot 3.2 made them trivially easy to enable. If you’re running any I/O-heavy workloads—REST APIs, database queries, external service calls—this is one of the most impactful changes you can make without rewriting your application.
This guide covers how virtual threads actually work, how Spring Boot’s auto-configuration wires them in, when they help (and when they don’t), and the gotchas that will bite you in production.
What Are Virtual Threads?
Traditional Java threads map 1:1 to OS threads. Creating one costs roughly 1MB of stack memory, and context switching between them goes through the OS scheduler. Under heavy load, thread pools become a limiting factor—when all threads are blocked waiting on I/O, new requests queue up.
Virtual threads are managed by the JVM, not the OS. They’re lightweight: you can create millions of them. The JVM scheduler mounts virtual threads onto a small pool of OS “carrier threads.” When a virtual thread blocks on I/O, it gets unmounted (parked), freeing the carrier thread to run another virtual thread.
// Platform thread — maps to an OS thread
Thread platformThread = new Thread(() -> doSomething());
// Virtual thread — managed by the JVM
Thread virtualThread = Thread.ofVirtual().start(() -> doSomething());
// Or via factory
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> doSomething());
The key insight: with virtual threads, blocking is cheap. Your code can be written in the straightforward blocking style (no CompletableFuture chains, no reactive operators) and still scale to high concurrency.
Spring Boot 3.2+ Auto-Configuration
Spring Boot 3.2 introduced first-class virtual thread support. Enable it with a single property:
# application.yml
spring:
threads:
virtual:
enabled: true
Or in properties format:
# application.properties
spring.threads.virtual.enabled=true
That’s it. When this is set, Spring Boot automatically:
- Configures Tomcat (or Jetty/Undertow) to use a virtual thread executor for request handling
- Uses virtual threads for
@Asynctasks - Configures Spring’s task scheduler with virtual threads
- Enables virtual thread support in Spring WebFlux’s blocking calls
What Gets Configured
Here’s what Spring Boot does under the hood when virtual threads are enabled:
// Spring Boot auto-configures this for Tomcat
@Bean
@ConditionalOnProperty("spring.threads.virtual.enabled")
public TomcatProtocolHandlerCustomizer<?> virtualThreadCustomizer() {
return protocolHandler ->
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}
// And for @Async
@Bean
@ConditionalOnProperty("spring.threads.virtual.enabled")
public AsyncTaskExecutor applicationTaskExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
You don’t need to write any of this. The property handles it.
Maven/Gradle Setup
You need Java 21 and Spring Boot 3.2+:
<!-- pom.xml -->
<properties>
<java.version>21</java.version>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
// build.gradle.kts
java {
sourceCompatibility = JavaVersion.VERSION_21
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
// version managed by Spring Boot BOM
}
Practical Example: REST Controller with Database Calls
A typical Spring Boot controller with blocking database access is exactly where virtual threads shine:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderRepository orderRepository;
private final InventoryClient inventoryClient;
private final PaymentClient paymentClient;
public OrderController(OrderRepository orderRepository,
InventoryClient inventoryClient,
PaymentClient paymentClient) {
this.orderRepository = orderRepository;
this.inventoryClient = inventoryClient;
this.paymentClient = paymentClient;
}
@GetMapping("/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
// All these calls block — but that's fine with virtual threads
Order order = orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
InventoryStatus inventory = inventoryClient.checkInventory(order.getItemId());
PaymentStatus payment = paymentClient.getPaymentStatus(order.getPaymentId());
return new OrderResponse(order, inventory, payment);
}
}
With platform threads and a default Tomcat pool of 200 threads, this endpoint can handle 200 concurrent requests before requests start queuing. With virtual threads, you can handle tens of thousands of concurrent blocked requests without exhausting resources.
Performance Benchmarks: Real Numbers
Virtual threads help specifically with I/O-bound workloads. Here’s what to expect:
Scenario: REST endpoint making a 50ms database query
| Configuration | Concurrent Requests | Throughput (req/s) | P99 Latency |
|---|---|---|---|
| Tomcat default (200 threads) | 200 | ~3,800 | 55ms |
| Tomcat default (200 threads) | 1,000 | ~3,800 | 250ms |
| Virtual threads | 200 | ~3,800 | 52ms |
| Virtual threads | 1,000 | ~18,000 | 56ms |
| Virtual threads | 10,000 | ~18,000 | 58ms |
At low concurrency, you’ll see little difference. The gains appear when your concurrency exceeds your thread pool size—virtual threads let the JVM keep all those blocked requests in-flight without the overhead of thousands of OS threads.
CPU-bound workloads see no improvement. If your bottleneck is computation rather than waiting, virtual threads don’t help and may slightly hurt due to scheduler overhead.
When to Use Virtual Threads
Good candidates:
- REST APIs that call databases, external services, or message queues
- Batch processing with lots of I/O per record
- Services that make multiple blocking calls per request
- Applications currently constrained by thread pool size
Poor candidates:
- CPU-intensive work (image processing, cryptography, data transformation)
- Applications already using reactive programming (Project Reactor, RxJava)—you’re likely already handling blocking well
- Very short-lived requests with minimal I/O
The Pinning Problem
This is the gotcha most tutorials gloss over. A virtual thread gets pinned to its carrier thread when:
- It executes a
synchronizedblock or method - It calls a native method
When pinned, the carrier thread can’t unmount the virtual thread to run others. If you have enough pinned virtual threads, you’ll exhaust your carrier thread pool and see unexpected latency spikes.
// This pins the virtual thread to its carrier thread
// during the entire execution of the synchronized block
public synchronized void updateCache(String key, String value) {
// If this does I/O (e.g., calling an external service),
// the carrier thread is stuck here
externalService.notify(key, value); // PROBLEM: blocks the carrier thread
cache.put(key, value);
}
Fix: Use ReentrantLock instead of synchronized:
private final ReentrantLock lock = new ReentrantLock();
public void updateCache(String key, String value) {
lock.lock();
try {
// Virtual thread can be unmounted here while waiting on I/O
externalService.notify(key, value);
cache.put(key, value);
} finally {
lock.unlock();
}
}
ReentrantLock doesn’t pin virtual threads. Make this change wherever you have synchronized blocks that contain I/O operations.
Detecting Pinning
Enable JVM diagnostic output to see pinning events:
java -Djdk.tracePinnedThreads=full -jar your-app.jar
Or in your Spring Boot application.yml:
spring:
application:
name: my-service
logging:
level:
root: INFO
# Add JVM flags in your startup script or Dockerfile
JAVA_OPTS="-Djdk.tracePinnedThreads=short"
When pinning occurs, you’ll see output like:
Thread[#25,ForkJoinPool-1-worker-1,5,CarrierThreads]
com.example.MyService.updateCache(MyService.java:45) <== monitors:1
ThreadLocal Gotchas
With platform threads and fixed-size pools, ThreadLocal is commonly used to store per-request context (user ID, correlation ID, etc.). Virtual threads complicate this in a subtle way.
The problem isn’t that ThreadLocal doesn’t work—it does. The problem is that with virtual threads, the JVM creates a new thread per task by default. That means your ThreadLocal values are properly scoped to each request, but the lifecycle is now different from what you might expect.
// This works correctly with virtual threads
// Each virtual thread gets its own ThreadLocal
private static final ThreadLocal<String> currentUser = new ThreadLocal<>();
@GetMapping("/profile")
public UserProfile getProfile() {
// This is unique to this virtual thread (request)
currentUser.set(SecurityContextHolder.getContext().getAuthentication().getName());
return userService.getProfile(currentUser.get());
}
However, if you’re using a library that relies on InheritableThreadLocal for propagating context to child threads (like some tracing or MDC implementations), verify it handles virtual threads correctly. Most modern versions of these libraries do, but it’s worth checking.
Spring Security and Spring’s request-scoped beans work fine—they use ThreadLocal internally and are well-tested with virtual threads.
Connection Pool Sizing
Here’s a counterintuitive implication: when you enable virtual threads, your database connection pool becomes more important, not less.
With platform threads and a 200-thread Tomcat pool, you’ll never need more than 200 DB connections simultaneously. With virtual threads, you could have 10,000 concurrent requests, each wanting a DB connection. If your connection pool has 200 connections, those 10,000 requests queue up waiting for a connection—and suddenly the connection pool is your bottleneck, not the thread pool.
# application.yml — tune your connection pool to match expected concurrency
spring:
datasource:
hikari:
maximum-pool-size: 50 # Start here and measure
minimum-idle: 10
connection-timeout: 30000 # 30 seconds max wait for a connection
idle-timeout: 600000
max-lifetime: 1800000
Don’t blindly crank this up. Database connection pools should be sized based on what your database can handle, not what your application wants. Benchmark with realistic load before adjusting.
Migrating an Existing Spring Boot Application
If you’re on Spring Boot 3.2+ with Java 21, migration is minimal:
- Enable the property:
spring.threads.virtual.enabled=true - Audit
synchronizedblocks: Find anysynchronizedmethods/blocks that contain I/O and replace withReentrantLock - Check your dependencies: Third-party libraries may use
synchronizedinternally. Check the pinning output in testing. - Adjust connection pools: HikariCP, Redis connection pools, HTTP client pools
- Load test: Verify your actual performance under realistic concurrency
// Before: using synchronized with I/O inside
@Component
public class RateLimiter {
private final Map<String, AtomicInteger> counts = new ConcurrentHashMap<>();
// synchronized + I/O = pinning risk
public synchronized boolean isAllowed(String clientId) {
int count = counts.computeIfAbsent(clientId, k -> new AtomicInteger(0)).get();
if (count >= LIMIT) {
auditService.logRateLimitExceeded(clientId); // I/O inside synchronized!
return false;
}
counts.get(clientId).incrementAndGet();
return true;
}
}
// After: ReentrantLock allows virtual thread unmounting
@Component
public class RateLimiter {
private final Map<String, AtomicInteger> counts = new ConcurrentHashMap<>();
private final ReentrantLock lock = new ReentrantLock();
public boolean isAllowed(String clientId) {
lock.lock();
try {
int count = counts.computeIfAbsent(clientId, k -> new AtomicInteger(0)).get();
if (count >= LIMIT) {
lock.unlock(); // Release before I/O
try {
auditService.logRateLimitExceeded(clientId);
} finally {
// Don't re-acquire — we're done with the critical section
}
return false;
}
counts.get(clientId).incrementAndGet();
return true;
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
Virtual Threads vs. Reactive Programming
If you’re already on Spring WebFlux, should you switch to virtual threads?
Probably not, at least not immediately. Reactive programming gives you similar scalability characteristics and has good ecosystem support. Virtual threads give you the same scalability with synchronous, blocking code—which many teams find easier to reason about, debug, and test.
If you’re starting a new project and don’t have specific reasons for reactive programming (streaming responses, SSE, complex async composition), virtual threads with Spring MVC is now a compelling option.
If you’re on Spring WebFlux but find the reactive model complex to maintain, a migration to Spring MVC + virtual threads is worth considering.
Frequently Asked Questions
What Java and Spring Boot versions are required for virtual threads?
Virtual threads require Java 21 or later (they were in preview in Java 19-20 but became stable in Java 21). For Spring Boot, version 3.2 or later is recommended—it introduced first-class virtual thread support via the spring.threads.virtual.enabled=true property. Earlier Spring Boot 3.x versions require manual executor configuration to enable virtual threads.
Do virtual threads replace reactive programming with Spring WebFlux?
Virtual threads and reactive programming solve the same problem—high concurrency with I/O-bound workloads—but with different approaches. Virtual threads let you write standard blocking code with the scalability of non-blocking I/O. WebFlux requires a reactive programming model (Project Reactor) which has a steeper learning curve but mature ecosystem support. If you’re starting a new project, virtual threads with Spring MVC is now a compelling choice. If you’re already on WebFlux and it’s working well, there’s no urgency to switch.
What is thread pinning and how do I detect it in my Spring Boot application?
Thread pinning happens when a virtual thread cannot be unmounted from its carrier (OS) thread—typically when it enters a synchronized block and then performs a blocking I/O operation. The virtual thread is “pinned” to the carrier thread, which defeats the scalability benefit. Detect pinning by running with the JVM flag -Djdk.tracePinnedThreads=full, which prints a stack trace whenever pinning occurs. Common causes in Spring Boot applications include synchronized blocks in JDBC drivers, some third-party libraries, and legacy code.
How should I size database connection pools when using virtual threads?
With virtual threads you can handle far more concurrent requests, which means your connection pool can become the bottleneck faster than before. Counterintuitively, you often want a smaller pool with virtual threads, sized to match your database’s actual capacity (number of CPU cores on the DB server × 2-4 is a common starting point), not the number of application threads. With HikariCP, set maximumPoolSize based on what your database can handle, not on expected application concurrency. Monitor connection wait times and adjust accordingly.
Are virtual threads faster than platform threads for CPU-bound work?
No. Virtual threads do not improve CPU-bound work—they’re designed specifically for I/O-bound workloads where threads spend most of their time waiting (on network, disk, or database). For CPU-intensive operations like video encoding, heavy computation, or number crunching, platform threads are equivalent and the overhead of virtual thread management may actually make performance slightly worse. Virtual threads shine in applications with high concurrency and lots of blocking I/O, which describes the vast majority of Spring Boot web applications.
Summary
Java virtual threads with Spring Boot 3.2+ give you high-concurrency I/O handling without reactive programming. The configuration is a single property. The main things to watch:
- Pinning: Replace
synchronizedblocks that contain I/O withReentrantLock - Connection pools: Size them for your actual concurrency, not your old thread pool size
- CPU-bound work: Virtual threads don’t help here
- Existing reactive apps: No need to migrate if you’re already scaling well
For most teams running standard Spring Boot applications with database and HTTP I/O, this is a straightforward win. Enable it, run your load tests, check for pinning, tune your pools.
Related Articles
- GraalVM Native Image with Spring Boot — Another major performance lever: compile your Spring Boot app to a native binary for near-instant startup.
- Spring Boot Microservices Architecture Patterns — Virtual threads change how you scale microservices—see how they fit broader architecture patterns.
- Java 25 Performance Breakthrough: 30% CPU Reduction — Virtual threads are one of several Java performance improvements—see what else Java 25 brings.
- Spring Boot REST API Best Practices for Production — Virtual threads work hand-in-hand with well-designed REST APIs to handle high concurrency.