Java virtual threads change the behavior of I/O services but shift bottlenecks to other layers. We analyze where the system benefits and where it starts to break down.
The problem manifests immediately after the “one-liner” enabling of virtual threads in Spring Boot 3. On synthetic benchmarks, everything looks better: higher throughput, lower latency. But under load, hidden dependencies emerge. Synchronization (synchronized), the use of ThreadLocal, and connection pool sizes start to behave differently. In JDK 21, a key failure is the exhaustion of carrier threads due to pinning within synchronized. A virtual thread is blocked on I/O but does not release the carrier. During a spike in contention, all carrier threads become busy, and the scheduler cannot advance the work. The symptom appears as a “silent stop”: the JVM is alive, but traffic is not being serviced, some sockets hang in CLOSE_WAIT, and standard thread dumps do not reveal the problem.
The solution in JDK 24 (JEP 491) is to remove pinning at the monitor level. Virtual threads can now “unmount” within synchronized and correctly return to the monitor after waiting. This alleviates the primary class of failures and makes the transition to virtual threads a pragmatic choice for I/O-intensive services. However, this is not a free upgrade. Residual cases of pinning remain: native calls and file I/O on Linux. There is no standard integration with io_uring for them, meaning locks may persist. The compromise is clear: we gain in scalability and isolation but must accept network and system limitations and pay closer attention to library behavior.
Implementation requires two parallel tracks. The first is the observability of pinning. JFR should log jdk.VirtualThreadPinned events, as the old tracing flag has been removed. This allows for the localization of rare but critical sections. The second is auditing code and dependencies. The main “silent” failure is associated with ThreadLocal. Historically, it is designed for long-lived platform threads and reuse. In the virtual model, the thread is short-lived, and ThreadLocalMap is created anew for each request. The cache “works,” but stops caching: the value is recreated every time without errors or warnings. The side effect is an increase in allocation rate and more frequent GC. Observations show that virtual threads exhibit a more “jagged” heap profile and additional GC pauses, yet remain faster in throughput and p99 latency. GC does not become a bottleneck, but its activity increases proportionally to allocations.
A practical approach is to instrument ThreadLocal. Override initialValue() and count accesses. This quickly identifies the “cache that does not cache.” Concurrently, patterns need to be revisited: replace ThreadLocal with Scoped Values (where applicable), move heavy objects to explicit pools, or make them stateless. A separate area is the connection pool. When the thread limit disappears, the bottleneck shifts downstream: databases and external APIs. Increasing the number of concurrent requests without revising limits leads to longer queues and worse tail latency. There is no magic in JDK: it is necessary to align pool size, rate limiting, and the throughput of dependencies.
The results of a controlled benchmark confirm a dual picture. For I/O paths with artificial latency downstream, virtual threads achieve approximately 2-3 times greater throughput with a comparable number of resources. p99 latency is consistently lower than that of platform threads. At the same time, the volume of allocations and the frequency of GC pauses (within a few milliseconds) increase, reflecting the abandonment of ThreadLocal cache reuse. Metrics show a shift in the bottleneck: from managing the thread pool to managing external resources and allocations. Importantly, without changes in code, “hidden” degradations can occur: lost context transfer (InheritableThreadLocal) and non-obvious cache misses.
The conclusion appears as an evolutionary improvement rather than a universal acceleration. Java virtual threads are well-suited for I/O-intensive services and simplify the concurrency model. However, successful implementation is not just a flag in the configuration. Pinning checks via JFR, ThreadLocal audits, and a review of connection pools and downstream expectations are necessary. Otherwise, the system may indeed become faster—but will start losing stability elsewhere.