The Unexpected Throughput Plateau
A recent migration to Java's virtual threads promised massive scalability. The reality, however, was a perplexing performance ceiling. A service, freshly moved to virtual threads, hit a hard limit of approximately 420 requests per second. This occurred despite ample traffic, low CPU utilization (around 9%), and no errors or warnings logged. The underlying machine boasted 8 cores. With a critical downstream HTTP call on the hot path taking roughly 19ms, the theoretical maximum throughput, calculated as 8 cores * (1000ms / 19ms per request), was precisely 421 requests per second. The service, designed to handle millions of concurrent virtual threads, was effectively serving only one request per available OS thread. This phenomenon, where virtual threads unexpectedly revert to behaving like a traditional bounded thread pool, has a name: pinning. This guide aims to demystify pinning, explain its causes, detail recent changes in JDK 24, and provide methods for detection before your application's performance graph flattens unexpectedly.

Understanding Virtual Thread Pinning
At its core, a virtual thread is not directly tied to a specific operating system (OS) thread. Instead, it mounts and unmounts from a pool of OS threads, allowing a large number of virtual threads to share a smaller number of OS threads. This is the mechanism that enables massive concurrency. Pinning occurs when a virtual thread, while running, becomes bound to an OS thread for an extended period, preventing that OS thread from unmounting and servicing other virtual threads. When this happens, the virtual thread is effectively stuck on its OS thread, much like a traditional platform thread. The illusion of infinite scalability shatters because the number of active, pinned virtual threads directly correlates to the number of available OS threads. The service, which should have scaled effortlessly, was instead limited by the fixed capacity of its underlying OS thread pool.
The Two Causes of Pinning
Pinning is not an arbitrary occurrence; it stems from two specific, identifiable coding patterns:
- Synchronous Blocking Operations: This is the most common culprit. When a virtual thread executes a blocking I/O operation that is not integrated with the Java Virtual Machine's (JVM) non-blocking I/O (NIO) framework, it can cause pinning. Traditional blocking calls, such as certain I/O operations or methods that acquire intrinsic locks without yielding, force the virtual thread to remain mounted on its OS thread. The JVM's virtual thread scheduler cannot unmount the thread while it's executing such a blocking operation. This means the OS thread is occupied and cannot serve any other virtual thread, leading to the thread pool effect. Code that uses older, synchronous APIs without proper asynchronous alternatives is particularly vulnerable.
- Native Code Execution: Invoking native code via the Java Native Interface (JNI) can also lead to pinning. If the native code performs a blocking operation or takes an unpredictable amount of time without yielding control back to the JVM, the virtual thread executing that native code becomes pinned to its OS thread. The JVM has limited visibility into what happens within native code, making it difficult to manage the virtual thread's lifecycle effectively during such calls. This is especially problematic for libraries that rely heavily on native components or legacy codebases that interact with native libraries.
It is crucial to understand that not all blocking operations cause pinning. The JVM is designed to handle many standard I/O operations (like reading from or writing to sockets) asynchronously. When a virtual thread performs such an operation, the JVM can safely unmount the virtual thread, allowing the OS thread to be used by another virtual thread while the I/O completes in the background. Pinning occurs when the blocking operation is one that the JVM cannot safely interrupt or yield from, effectively holding the OS thread hostage.
JDK 24 and the `jdk.virtualThread` API
The JDK development team has been actively addressing the challenges of virtual threads, including pinning. A significant development, particularly relevant for those using the latest Java versions, is the introduction of experimental APIs in JDK 24 that provide more control and visibility over virtual thread behavior. While not a complete eradication of pinning, these advancements aim to make it more manageable.
The experimental `jdk.virtualThread` package, introduced in JDK 24, offers APIs that allow developers to better understand and potentially mitigate pinning scenarios. For instance, it provides mechanisms to check if a virtual thread is pinned and to gain insights into the underlying OS thread usage. While these APIs are still experimental and subject to change, they signal a direction towards more robust tooling for managing virtual threads. Developers can use these new tools to instrument their code, identify potential pinning points, and make informed decisions about refactoring blocking operations.
A key aspect of this evolution is the ongoing refinement of the JVM's scheduler. Future JDK releases will likely see improved heuristics for detecting and handling potentially pinning operations, as well as more sophisticated ways to manage the lifecycle of virtual threads interacting with native code. The goal is to make the transition to virtual threads as seamless as possible, ensuring that developers can leverage their benefits without falling into subtle performance traps.
Detecting and Preventing Pinning
Proactive detection and prevention are key to avoiding the performance pitfalls of pinning. Relying solely on logs or error messages is insufficient, as pinning often manifests as a silent throughput plateau with no overt signs of distress.
Detection Strategies:
- Load Testing: Regular, robust load testing is paramount. Simulate realistic traffic patterns and observe throughput against CPU utilization. A disproportionately low throughput relative to available CPU cores is a strong indicator of pinning.
- Profiling Tools: Utilize Java profiling tools (like VisualVM, JProfiler, or Flight Recorder) to monitor thread activity. Look for OS threads that remain occupied by virtual threads for extended durations, especially during periods of high load. Specifically, observe the state of the carrier threads (the OS threads that virtual threads mount on).
- Thread Dump Analysis: Periodically take thread dumps during load tests. Analyze the stack traces of platform threads. If you consistently see platform threads stuck executing long-running synchronous operations or native calls while your application is under load, pinning is likely.
- Metrics Monitoring: Implement custom metrics to track the number of active virtual threads versus the number of active platform threads. A significant and consistent disparity, especially under load, can signal pinning. Monitoring metrics like `VirtualThread.getThreads()` and `Thread.activeCount()` in conjunction with CPU usage can be revealing.
Prevention and Mitigation:
- Embrace Asynchronous APIs: Wherever possible, refactor synchronous, blocking I/O operations to their asynchronous counterparts. Libraries like Netty, Vert.x, or the reactive extensions (e.g., Project Reactor, RxJava) provide non-blocking alternatives that work seamlessly with virtual threads.
- Review Native Code: If your application uses JNI, carefully audit the native code for blocking operations. Consider rewriting critical native sections to be non-blocking or to yield control back to the JVM periodically. If rewriting is not feasible, isolate native calls to dedicated platform threads managed explicitly.
- Use `StructuredTaskScope` Wisely: For concurrent tasks that might involve blocking, the `StructuredTaskScope` API (introduced in Java 21) can be used. It allows for better management of concurrent subtasks, including those that might block, by running them within a managed scope that can be shut down if necessary. While not a direct pinning solution, it aids in managing concurrent execution more predictably.
- Upgrade to Latest JDK: Keep your Java version updated. Newer JDKs, including the experimental features in JDK 24 and beyond, incorporate scheduler improvements and better tools for managing virtual threads and detecting pinning.
By understanding the root causes and employing these detection and prevention strategies, developers can harness the full potential of Java's virtual threads without succumbing to the silent performance killer that is pinning.
