跪拜 Guibai
← All articles
Backend

Two Years In, We Pulled Java Virtual Threads from Production

By 程序猿大帅 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Virtual threads are marketed as a drop-in concurrency upgrade, but this production postmortem shows the upgrade breaks silently: synchronized pinning, ThreadLocal leaks, and tooling gaps don't appear in benchmarks but surface under real load. Teams planning a JDK 21 migration need to audit third-party libraries for synchronized blocks, enforce ThreadLocal cleanup, and re-provision every connection pool before flipping the switch.

Summary

After six months of canary testing and production operation, a high-traffic API gateway team reverted two core services from virtual threads back to platform threads. The initial 30% throughput boost and 40% P99 latency drop masked deeper problems: synchronized blocks in third-party libraries pinned virtual threads to carrier threads, ThreadLocal leaks multiplied across far more thread instances, and monitoring tools like Arthas and JFR offered no visibility into virtual thread state. Connection pools for HTTP and databases stayed at pre-upgrade limits, so higher concurrency just meant more threads waiting on the same scarce resources.

The team kept virtual threads for IO-intensive batch processing and simple HTTP call patterns where the code was clean of synchronized and ThreadLocal misuse. CPU-bound tasks, real-time trading paths, and the high-throughput gateway itself went back to platform threads because P99 jitter and tooling gaps were unacceptable. The core lesson is that virtual threads simplify the programming model but amplify every existing weakness in dependency hygiene, observability, and resource sizing.

Takeaways
Frequent synchronized blocks pin virtual threads to carrier threads, erasing the throughput advantage and pushing P99 latency 63% higher than platform threads.
Third-party libraries with internal synchronized code (database drivers, HTTP clients, Logback appenders) are the hardest pinning sources to fix.
Virtual thread pools cache instances, so ThreadLocal objects set but not cleaned up persist and leak memory across far more thread instances than before.
Spring Security's SecurityContextHolder, MDC, and AOP interceptors are common sources of implicit ThreadLocal usage that must be wrapped in try-finally cleanup.
Using a fixed-size platform thread pool to manage virtual threads neuters their main benefit; use Executors.newVirtualThreadPerTaskExecutor with a Semaphore to cap concurrency.
Arthas cannot display virtual threads, JFR events are incomplete, and Micrometer metrics don't distinguish virtual from platform threads without custom configuration.
Tomcat's threads.max config changes meaning in virtual thread mode from max concurrent requests to request queue length, causing unexpected queuing.
HTTP and database connection pools sized for platform-thread concurrency become bottlenecks when virtual threads multiply concurrent requests.
Virtual threads delivered clear wins only for IO-intensive tasks with clean code; CPU-bound and latency-sensitive paths were rolled back.
Conclusions

The gap between virtual-thread marketing and production readiness is widest in observability: the JVM ecosystem's debugging tools were built around platform threads and have not caught up.

Virtual threads don't create new scaling limits; they just move the bottleneck from thread count to connection pools, memory, and GC, which many teams discover only after deployment.

The synchronized pinning problem means virtual-thread adoption is effectively gated by dependency hygiene, a factor no benchmark captures but every real codebase confronts.

ThreadLocal leaks become a multiplier problem under virtual threads because the thread count can be orders of magnitude larger, turning a minor leak into a major GC event.

Concepts & terms
Virtual Thread Pinning
When a Java virtual thread enters a synchronized block or calls a native method, it cannot unmount from its carrier platform thread. This pins the carrier thread, preventing it from executing other virtual threads and causing thread-pool exhaustion under load.
Carrier Thread
The underlying platform thread that actually executes a virtual thread. The JVM schedules many virtual threads onto a small pool of carrier threads; pinning one virtual thread blocks its carrier thread entirely.
ThreadLocal Leak Amplification
Virtual thread pools cache thread instances for reuse. If ThreadLocal values are set but not explicitly removed, they persist across task boundaries. Because virtual thread counts can be much higher than platform thread counts, the memory impact of leaks is multiplied.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗