Java Virtual Threads in Production: A 10x QPS Gain and the Pinning Traps That Come with It
A year ago, I switched my online service's threading model from thread pools to virtual threads, and QPS jumped from 800 to 8,500. Today, I'm sharing all the hands-on experience and pitfalls I've encountered over the past year.
Hello everyone, I'm Curly Hair.
After Java 21 officially released virtual threads last year, I immediately piloted them in two projects. Over the past year, I've had some truly delightful experiences and some hard-learned lessons. This article is not hype or criticism, just a recounting of real experiences.
1. Why Use Virtual Threads?
Let's start with the conclusion: If your service is I/O-intensive, virtual threads can make your performance soar.
The problems with traditional thread pools:
// Traditional thread pool — 200 threads is the ceiling
ExecutorService pool = Executors.newFixedThreadPool(200);
// Each thread occupies about 1MB of stack space
// 200 threads = 200MB of memory
// Moreover, with a high number of threads, context switching overhead becomes significant
// Database connection pools typically have only 50-100 connections
// 200 threads competing for 100 connections means most of the time is spent waiting
The fundamental difference with virtual threads:
// Virtual threads — easily create tens of thousands
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// Submitting 10,000 tasks simultaneously, no pressure at all
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
// Each task runs in its own virtual thread
// Automatically yields the carrier thread when blocked by I/O
return fetchUserFromDB(i);
});
});
}
My Service's Measured Data
# Test environment: 4 cores 8GB, MySQL 8.0, Redis 7.0
# Endpoint: User info aggregation query (DB query + Redis query + external API call)
# Thread pool mode (200 threads)
QPS: 812
Average response time: 245ms
P99 response time: 890ms
CPU usage: 45%
Memory usage: 1.2GB
# Virtual thread mode
QPS: 8,530 (10.5x improvement)
Average response time: 118ms
P99 response time: 320ms
CPU usage: 62%
Memory usage: 1.5GB
Why did QPS increase 10x? Because virtual threads automatically yield the carrier thread to other virtual threads when blocked by I/O. 200 platform threads can host tens of thousands of virtual threads, so I/O wait time is no longer wasted.
2. Migration in Practice: 3 Steps to Complete the Transformation
Step 1: Replace the Thread Pool
// ❌ Before
@Bean
public ExecutorService orderExecutor() {
return new ThreadPoolExecutor(
20, 200, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
new ThreadFactoryBuilder().setNameFormat("order-pool-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
// ✅ After
@Bean
public ExecutorService orderExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
Step 2: Enable Virtual Threads in Spring Boot Configuration
# application.yml
spring:
threads:
virtual:
enabled: true # Tomcat uses virtual threads to handle requests
With one line of configuration, Tomcat's request processing threads automatically switch to virtual threads.
Step 3: Handle synchronized Code Blocks
This is the biggest pitfall. Virtual threads inside synchronized code blocks will pin the carrier thread, preventing it from yielding. This is a known issue in Java 21, and Java 24+ has fixed most pinning scenarios.
// ❌ Pinning risk (Java 21)
public synchronized User getUser(Long id) {
// synchronized + I/O operation = carrier thread is pinned
return userDao.findById(id); // Cannot yield when blocked by database query
}
// ✅ Use ReentrantLock instead
private final Lock lock = new ReentrantLock();
public User getUser(Long id) {
lock.lock();
try {
return userDao.findById(id);
} finally {
lock.unlock();
}
}
Detecting pinning: Add the JVM parameter -Djdk.tracePinnedThreads=full at startup to print all pinning events.
3. Five Pitfalls Encountered Over the Year
Pitfall 1: Database Connection Pool Overwhelmed
Under virtual threads, concurrency skyrockets, but the database connection pool remains the same size.
// Problem: 10,000 virtual threads querying the DB simultaneously, but the connection pool only has 50 connections
// Result: A large number of virtual threads are waiting for connections, making things even slower
// Solution: Reasonably set the connection pool size + use an Adaptive Lupos connection pool
// HikariCP recommended configuration
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(50); // Don't blindly increase it, the DB can't handle it
config.setMinimumIdle(10);
config.setConnectionTimeout(30000);
// Or use a connection pool that supports virtual threads
// e.g., AgroalDataSource (supports virtual thread awareness)
Pitfall 2: ThreadLocal Memory Explosion
// ❌ Each virtual thread has its own ThreadLocal copy
// 100,000 virtual threads × 1KB stored per ThreadLocal = 100MB!
// ✅ Use ScopedValue instead (Java 21+)
private static final ScopedValue<UserContext> CONTEXT = ScopedValue.newInstance();
ScopedValue.where(CONTEXT, userContext).run(() -> {
userService.process();
});
// Automatically cleaned up after the scope ends, no accumulation
Pitfall 3: synchronized + I/O Causes Performance Degradation
// ❌ This method will pin the carrier thread under high concurrency
public synchronized void writeToCache(String key, String value) {
redisClient.set(key, value); // I/O operation inside a synchronized block
}
// ✅ Replace with ReentrantLock
private final ReentrantLock cacheLock = new ReentrantLock();
public void writeToCache(String key, String value) {
cacheLock.lock();
try {
redisClient.set(key, value);
} finally {
cacheLock.unlock();
}
}
Pitfall 4: Implicit synchronized in Third-Party Libraries
// Some third-party libraries use synchronized internally
// e.g., SimpleDateFormat, certain JDBC drivers
// Solution: Check the library's documentation for virtual thread support
// or upgrade to a version that supports virtual threads
// Date formatting — use DateTimeFormatter instead of SimpleDateFormat
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// DateTimeFormatter is thread-safe and doesn't need synchronized
Pitfall 5: Virtual Threads Are Not a Silver Bullet
// ❌ Using virtual threads for CPU-intensive tasks actually makes them slower
// The advantage of virtual threads is yielding the carrier thread during I/O blocking
// CPU-intensive tasks don't block, so virtual threads offer no advantage
// Instead, they add scheduling overhead
// ✅ Continue using platform threads for CPU-intensive tasks
ExecutorService cpuPool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);
// ✅ Use virtual threads for I/O-intensive tasks
ExecutorService ioPool = Executors.newVirtualThreadPerTaskExecutor();
4. Summary of Best Practices
Scenarios for Using Virtual Threads
// 1. HTTP request handling (Spring Boot has built-in support)
// 2. Database queries
// 3. Calling external APIs
// 4. Message queue consumption
// 5. File I/O operations
// Typical pattern: Aggregating multiple I/O calls
public UserDetailDTO getUserDetail(Long userId) {
try (var scope = StructuredTaskScope.open()) {
var userTask = scope.fork(() -> userService.findById(userId)); // DB query
var orderTask = scope.fork(() -> orderService.findByUserId(userId)); // DB query
var logTask = scope.fork(() -> logService.findByUserId(userId)); // DB query
var tagTask = scope.fork(() -> tagService.findByUserId(userId)); // Redis query
scope.join();
return UserDetailDTO.builder()
.user(userTask.get())
.orders(orderTask.get())
.logs(logTask.get())
.tags(tagTask.get())
.build();
}
// 4 queries executed in parallel, total time ≈ the slowest query
// Not the sum of 4 serial query times
}
Scenarios Where Virtual Threads Should Not Be Used
// 1. CPU-intensive computation
// 2. Scenarios requiring concurrency limiting (e.g., crawler rate limiting)
// 3. Scenarios relying on ThreadLocal accumulation
// 4. Old code heavily using synchronized (not refactored)
Migration Checklist
- Check all combinations of synchronized + I/O
- Evaluate the usage and memory footprint of ThreadLocal
- Check if the database connection pool configuration is reasonable
- Confirm whether third-party libraries are compatible with virtual threads
- Add pinning detection monitoring
- Stress test and compare performance metrics before and after migration
5. Performance Monitoring
Continuously monitor these metrics after going live:
// View the number of virtual threads
ManagementFactory.getThreadMXBean().dumpAllThreads(true, true);
// Monitor pinning events (development environment)
// JVM parameter: -Djdk.tracePinnedThreads=short
// Use Micrometer for monitoring
@Bean
public MeterRegistry meterRegistry() {
return new SimpleMeterRegistry();
}
// Monitor thread count, QPS, response time
// Recommended to use with Prometheus + Grafana dashboards
Final Words
Virtual threads are the most satisfying new Java feature I've used this year, bar none. But I've also seen many teams blindly deploy them and fail — not because of virtual threads themselves, but because they didn't do a proper migration assessment.
Virtual threads are powerful, but they are not a silver bullet. Understand the principles, evaluate the scenarios, and do the migration properly to truly enjoy the benefits of 10x throughput.
📌 I'm Curly Hair, a Java developer with 9 years of experience, focusing on practical Java technology sharing.
The virtual threads series will continue to be updated, including Structured Concurrency in Practice and Virtual Thread Troubleshooting Guide.
Follow "Curly Hair's Tech Notes" to receive update notifications first! 🔥
Are you using virtual threads? What pitfalls have you encountered? Let's chat in the comments 👇