Spring Boot with Virtual Threads in Production: Configuration, Pitfalls, and Benchmarks
Java virtual threads promise massive concurrency without reactive complexity, and Spring Boot virtual threads make enabling them a single configuration property. However, production deployment requires understanding connection pool sizing, thread pinning issues, and how virtual threads interact with existing frameworks. Therefore, this guide covers the practical aspects of running Spring Boot with virtual threads — from initial setup to production-hardened configuration.
Enabling Virtual Threads: One Property, Big Impact
Spring Boot 3.2+ supports virtual threads natively. Enable them with a single property, and every request handler runs on a virtual thread instead of a platform thread. The Tomcat/Jetty connector accepts requests and dispatches them to virtual threads, allowing thousands of concurrent connections without the traditional thread pool bottleneck.
# application.yml — enable virtual threads
spring:
threads:
virtual:
enabled: true
# That's it. Every @RestController, @Service method now runs on a virtual thread.
# No code changes needed — your existing blocking code works as-is.// What changes internally:
// Before (platform threads): Tomcat thread pool of 200 threads
// → Max 200 concurrent requests
// → Each thread: ~1MB stack memory
// → Total: ~200MB for thread stacks
//
// After (virtual threads): Unlimited virtual threads
// → Thousands of concurrent requests
// → Each virtual thread: ~1KB
// → Total: ~1MB for 1000 virtual threads
@RestController
public class OrderController {
@GetMapping("/orders/{id}")
public OrderDTO getOrder(@PathVariable Long id) {
// This method runs on a virtual thread
// Blocking calls are fine — JVM parks the virtual thread
Order order = orderRepository.findById(id).orElseThrow();
Customer customer = customerClient.getCustomer(order.getCustomerId());
List<Payment> payments = paymentClient.getPayments(order.getId());
return OrderDTO.from(order, customer, payments);
}
// All three blocking calls (DB, HTTP, HTTP) execute on the virtual thread
// JVM automatically parks/unparks the virtual thread during I/O waits
// A platform thread pool handles the actual I/O operations underneath
}Connection Pool Sizing: The Critical Configuration
With platform threads, you had 200 threads and 200 maximum concurrent database connections — naturally balanced. With virtual threads, you might have 10,000 concurrent requests, all trying to get a database connection from a pool of 10. This creates connection pool exhaustion — the most common virtual thread production issue.
# HikariCP configuration for virtual threads
spring:
datasource:
hikari:
maximum-pool-size: 50 # MORE than default 10, LESS than thread count
minimum-idle: 10
connection-timeout: 5000 # 5 seconds — fail fast
idle-timeout: 300000 # 5 minutes
max-lifetime: 600000 # 10 minutes
# CRITICAL: Use semaphore to limit concurrent DB access
# Without this, 10,000 virtual threads compete for 50 connections
# causing massive thread parking and connection timeout storms// Semaphore pattern to limit concurrent database access
import java.util.concurrent.Semaphore;
@Configuration
public class DatabaseConfig {
// Allow max 50 concurrent database operations
// Matches HikariCP pool size
private final Semaphore dbSemaphore = new Semaphore(50);
@Bean
public Semaphore databaseSemaphore() {
return dbSemaphore;
}
}
@Service
public class OrderService {
private final Semaphore dbSemaphore;
private final OrderRepository orderRepo;
public Order getOrder(Long id) throws InterruptedException {
dbSemaphore.acquire(); // Wait for available permit
try {
return orderRepo.findById(id).orElseThrow();
} finally {
dbSemaphore.release();
}
}
}
// Better approach: use a virtual-thread-aware connection pool
// or configure Spring's TaskDecorator to apply semaphore globallyThe general rule: set your connection pool to match the concurrency your database can actually handle (typically 20-100 connections per database instance), not the number of virtual threads. Additionally, monitor connection wait times and pool utilization to find the right balance for your workload.
Thread Pinning: When Virtual Threads Block Platform Threads
Virtual threads are supposed to park (release the platform thread) during blocking operations. However, two scenarios cause “pinning” — where the virtual thread holds onto the platform thread and can’t release it. This defeats the purpose of virtual threads because pinned threads consume platform threads just like traditional blocking.
// Pinning scenario 1: synchronized blocks
// The JVM cannot unmount a virtual thread inside a synchronized block
public class LegacyService {
private final Object lock = new Object();
public Data getData() {
synchronized (lock) { // PINS the virtual thread!
return database.query(); // Blocking I/O inside synchronized = bad
}
}
// Fix: Replace synchronized with ReentrantLock
private final ReentrantLock lock2 = new ReentrantLock();
public Data getDataFixed() {
lock2.lock(); // ReentrantLock does NOT pin
try {
return database.query(); // Virtual thread parks correctly
} finally {
lock2.unlock();
}
}
}
// Pinning scenario 2: Native methods / JNI calls
// Some JDBC drivers use native calls internally
// Monitor with: -Djdk.tracePinnedThreads=short
// This JVM flag logs when virtual threads get pinned
// Pinning scenario 3: Third-party libraries using synchronized
// Common culprits: older JDBC drivers, some HTTP clients, logging frameworks
// Check library documentation for virtual thread compatibilityRun your application with -Djdk.tracePinnedThreads=short in staging to identify pinning issues. The JVM logs a stack trace every time a virtual thread is pinned, showing exactly which code path caused it. Furthermore, most modern libraries (HikariCP 5+, Apache HttpClient 5, Netty 4.2+) have been updated to avoid pinning.
Performance Benchmarks: Virtual Threads vs Platform Threads
Real-world benchmarks show the impact of virtual threads on a typical Spring Boot REST API with database access and downstream HTTP calls:
Benchmark: Spring Boot 3.3 REST API
- 3 endpoints: GET order, POST order, GET orders (paginated)
- Each request: 1 DB query + 1 HTTP call to downstream service
- Database: PostgreSQL, HikariCP pool size 50
- Server: 4 vCPU, 8GB RAM
Platform Threads (200 thread pool):
Concurrent requests: 200 max
Throughput: 4,200 req/s
p50 latency: 18ms
p99 latency: 95ms
Memory: 420MB
At 500 concurrent: Thread pool exhaustion, 5s queue wait
Virtual Threads:
Concurrent requests: 10,000+ (limited by DB pool, not threads)
Throughput: 4,800 req/s (+14%)
p50 latency: 16ms
p99 latency: 52ms (42% better)
Memory: 190MB (55% less)
At 500 concurrent: No degradation
At 5,000 concurrent: p99 increases to 180ms (DB pool contention)
Key insight: Virtual threads don't make individual requests faster.
They prevent degradation under high concurrency.
The real win is at 500+ concurrent requests where platform threads
hit pool limits and virtual threads keep running smoothly.Production Checklist for Virtual Threads
Before deploying Spring Boot with virtual threads to production, verify these items:
- Connection pools sized correctly: Set HikariCP, HTTP client pools, and Redis pools based on downstream capacity, not virtual thread count.
- No thread pinning: Run with
-Djdk.tracePinnedThreads=shortin staging and fix synchronized blocks that contain I/O operations. - ThreadLocal usage audited: Virtual threads create thousands of instances. Large ThreadLocal values waste memory. Consider ScopedValues instead.
- Monitoring updated: Traditional thread metrics (pool size, active threads) are less meaningful. Monitor connection pool wait time, request queue depth, and actual concurrency instead.
- Libraries compatible: Verify JDBC driver, HTTP client, and connection pool versions support virtual threads without pinning.
Related Reading:
- Reactive vs Virtual Threads Comparison
- Java Pattern Matching Guide
- GraalVM Native Images with Spring Boot
Resources:
In conclusion, Spring Boot with virtual threads delivers significant concurrency improvements with minimal code changes. The critical production concerns are connection pool sizing, thread pinning detection, and ThreadLocal memory management. Start with the one-property enablement, test with realistic load in staging, and monitor connection pool metrics in production. Virtual threads don’t make requests faster — they prevent performance degradation under high concurrency.