跪拜 Guibai
← All articles
Backend · Java · Interview

Java Virtual Threads in Production: A 10x QPS Gain and the Pinning Traps That Come with It

By 卷毛的技术笔记 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

A 10× throughput improvement on the same hardware changes the cost calculus for I/O-heavy Java services. The migration is mechanically simple—swap an executor, flip a config flag, replace `synchronized` with locks—but the real risk is the pinning behavior that silently degrades performance when `synchronized` meets I/O, a trap that still exists in Java 21 and requires explicit detection.

Summary

A year-long production migration to Java 21 virtual threads turned a user-info aggregation endpoint—mixing database, Redis, and external API calls—from a thread-pool bottleneck into a high-throughput service. The same 4-core, 8 GB instance went from 812 QPS and 890 ms P99 latency under a 200-thread pool to 8,530 QPS and 320 ms P99 with virtual threads, at the cost of a modest CPU and memory increase.

The migration required three concrete steps: replacing `ThreadPoolExecutor` with `Executors.newVirtualThreadPerTaskExecutor()`, enabling Spring Boot’s `spring.threads.virtual.enabled` flag so Tomcat uses virtual threads, and systematically replacing `synchronized` blocks with `ReentrantLock` to avoid carrier-thread pinning. The biggest production surprises were connection-pool saturation under massive concurrency, `ThreadLocal` memory bloat from hundreds of thousands of lightweight threads, and hidden `synchronized` calls inside third-party libraries like `SimpleDateFormat`.

Virtual threads are not a universal upgrade. CPU-bound workloads still belong on fixed platform-thread pools, and any code path that combines `synchronized` with I/O will pin the carrier thread and destroy throughput. The author provides a migration checklist, JVM flags for pinning detection (`-Djdk.tracePinnedThreads=full`), and a `StructuredTaskScope` pattern that parallelizes multiple I/O calls so total latency equals only the slowest one.

Takeaways
Replacing a 200-thread pool with virtual threads on a 4-core, 8 GB instance raised QPS from 812 to 8,530 and dropped P99 latency from 890 ms to 320 ms for an I/O-heavy aggregation endpoint.
Virtual threads yield the carrier thread during I/O blocks, letting a few hundred platform threads host tens of thousands of virtual threads without the context-switching tax of large thread pools.
`synchronized` blocks pin the carrier thread in Java 21, preventing the yield that makes virtual threads fast; `ReentrantLock` avoids this entirely.
Add `-Djdk.tracePinnedThreads=full` at JVM startup to log every pinning event during development and staging.
Database connection pools become the new bottleneck: 10,000 virtual threads hitting a 50-connection pool just queue up, so pool sizing and virtual-thread-aware connection pools matter more.
`ThreadLocal` usage that was harmless with 200 threads can consume hundreds of megabytes with 100,000 virtual threads; `ScopedValue` (Java 21+) cleans up automatically when the scope ends.
CPU-bound workloads gain nothing from virtual threads and incur extra scheduling overhead; keep them on fixed platform-thread pools sized to available processors.
`StructuredTaskScope` forks multiple I/O calls in parallel so total latency equals the slowest call, not the sum of all calls.
Conclusions

The 10× QPS gain came from eliminating idle time, not from faster computation—virtual threads let the CPU stay busy while thousands of tasks wait on I/O, which is why CPU utilization rose only from 45% to 62%.

Pinning is the silent killer of virtual-thread performance: a single `synchronized` method that touches I/O can serialize an entire carrier thread and cascade into throughput collapse, yet it produces no exception or obvious log line without explicit tracing.

The migration checklist is deceptively short because the real work is auditing every dependency for hidden `synchronized` blocks—`SimpleDateFormat` and older JDBC drivers are common offenders that many teams will miss.

Virtual threads invert the traditional connection-pool tuning advice: with thread pools, you limit threads to match connections; with virtual threads, you must limit connections to protect the database from unbounded concurrency.

Concepts & terms
Virtual Thread (Java 21+)
A lightweight thread managed by the JVM rather than the OS. Virtual threads run on a small pool of platform (OS) threads and automatically unmount when they block on I/O, allowing the carrier thread to run another virtual thread. This makes it practical to have millions of concurrent tasks without the memory and context-switching cost of OS threads.
Carrier Thread Pinning
When a virtual thread executes inside a `synchronized` block or native method and then blocks, it cannot unmount from its carrier (platform) thread. The carrier thread is pinned and cannot run other virtual threads until the block completes, destroying the throughput benefit of virtual threads.
ScopedValue (Java 21+)
A preview API that provides scoped, immutable data sharing across threads within a bounded lifetime. Unlike `ThreadLocal`, values are automatically cleared when the scope exits, preventing memory leaks when millions of virtual threads are created and discarded.
StructuredTaskScope (Java 21+ preview)
An API for structured concurrency that treats a group of subtasks as a single unit of work. `scope.fork()` starts subtasks, `scope.join()` waits for all to complete, and the scope ensures cleanup. It makes parallel I/O calls behave like a single operation whose latency equals the slowest subtask.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗