Introduction #
Why Your Java 21 Virtual Threads Suddenly Stop Scaling
Java 21’s virtual threads were supposed to revolutionize concurrency—you can create millions of lightweight threads and write high-throughput code in a familiar synchronous style. Yet many teams deploying to production discovered the opposite: CPU remained stubbornly high, throughput cratered, and latency spikes became the norm. You switched to the virtual-thread-per-task model but saw none of the promised performance gains, and under load the service simply “froze.”
If you are experiencing this “virtual thread failure” in production, you have almost certainly run into a core problem: Virtual Thread Pinning (sometimes informally called “VirtualThreadPinning”). Even in 2026, with many libraries now adapted, understanding and eliminating pinning remains the critical lever for extracting the full scalability of virtual threads. This article is a complete production-grade guide—root causes, detection methods, concrete fixes, and an optimization strategy you can apply directly.
Quick Answer #
Virtual thread pinning happens when a virtual thread cannot be unmounted from its carrier thread during blocking operations, usually because of synchronized blocks or native calls. This eliminates the scalability benefits of Java 21 virtual threads and can cause severe throughput collapse under load.
What is Virtual Thread Pinning? #
Virtual Thread Pinning occurs when a virtual thread is forced to stay bound to its carrier thread, preventing the JVM from unmounting it during a blocking operation. The virtual thread loses its most critical lightweight scheduling ability.
Let’s capture the essence in one sentence:
A virtual thread loses its scalability advantage when it gets “pinned” to its carrier thread.
Under normal circumstances, when a virtual thread encounters a blocking I/O call, the JVM rapidly unmounts it from the carrier thread and releases that OS thread to serve other virtual threads. Pinning disables this unmounting entirely. The virtual thread remains glued to the precious carrier thread for the entire duration of the block. The result is a direct regression to the traditional thread model—one blocked task monopolizes one platform thread, causing the entire virtual thread pool’s scheduling capacity to collapse.
Typical pinning triggers include:
- A blocking operation executed inside a
synchronizedblock or method - Calling a native method that blocks (JNI / Foreign Function & Memory API)
- File I/O or network I/O performed within a pinned region
- Implicit synchronization inside old JDBC drivers or within the Spring Boot framework
Before we dissect how pinning destroys scalability, let’s establish the baseline of how virtual threads are supposed to work.
How Virtual Threads Normally Work (Baseline Model) #
In a pinning-free environment, the virtual thread lifecycle flows beautifully:
- A virtual thread is created and begins executing a task.
- The JVM picks a carrier thread (a platform thread) from its internal pool and mounts the virtual thread onto it.
- During execution, the virtual thread encounters a blocking call, for example
Socket.read(). - The JVM instantly detects the block, unmounts the virtual thread from the carrier thread, and preserves its stack.
- The carrier thread is freed and immediately picks up another ready virtual thread.
- When the blocking operation becomes unblocked, the JVM finds a new carrier thread and mounts the virtual thread again to resume execution.
The core advantages of this model are:
- No OS thread is blocked; a few dozen carrier threads can sustain millions of virtual threads.
- Context switching is extremely cheap, occurring primarily at mount/unmount boundaries.
- Developers keep writing simple synchronous blocking code while the runtime provides asynchronous scalability underneath.
This is exactly how virtual threads deliver high scalability with low overhead. But when pinning occurs, step 4—the unmount—never happens, and the whole elegant mechanism breaks apart.
What Causes Virtual Thread Pinning (Root Causes) #
1. synchronized Blocks (The Primary Killer)
#
Whenever you call any blocking operation from within a synchronized protected region, the virtual thread gets pinned.
synchronized(lock) {
String data = restTemplate.getForObject(url, String.class); // blocking
processData(data);
}
To guarantee correct synchronized semantics (the lock owner must be associated with a carrier thread), the JVM cannot unmount the virtual thread. The carrier thread therefore blocks along with the entire HTTP call and cannot schedule other virtual threads. If your system concurrently handles 1000 such requests using virtual threads, the small carrier thread pool quickly becomes fully pinned, freezing the entire service.
2. Native Method Calls (JNI / Foreign Calls) #
Many older JDBC drivers, encryption libraries, and compression libraries rely on JNI calls under the hood. If these native calls perform blocking operations (like socket communication), the JVM again cannot safely unmount the virtual thread. By 2026, most mainstream drivers have been re-implemented in pure Java or properly annotate blocking points, but in legacy systems or unupgraded libraries, this remains a significant risk.
3. Blocking I/O Inside a Pinned Region #
Even without native calls, some JVM-internal blocking operations (for example, file I/O in earlier Java versions) could fail to unmount under specific conditions. Although Java 21 made enormous improvements here, brief pinning can still happen in edge cases involving OS-level file locks or certain stream operations.
4. Hidden Synchronization in Spring Boot #
Many developers innocently add synchronized to Spring bean methods or rely on default singleton proxy locks. Within the transaction aspect (@Transactional) and dynamic proxy weaving, the AOP infrastructure sometimes acquires locks to maintain consistent state, which can trigger implicit pinning when SQL statements or remote calls are executed inside those locked sections. For example, if a transaction manager’s connection binding logic ends up wrapped in a synchronized block, it becomes a silent production time bomb.
Symptoms in Production #
| Symptom | Likely Cause |
|---|---|
| High CPU usage | Carrier threads pinned by blocking operations |
| Throughput collapse | Virtual threads cannot be rescheduled |
| P99 latency spikes | Carrier thread starvation |
| Service appears frozen | All carrier threads blocked |
Pinned virtual thread logs |
JVM detected pinning |
When Virtual Thread Pinning hits, your monitoring dashboards will light up with classic signs:
- Unexpectedly high CPU usage: Many carrier threads are stuck waiting due to pinning, while the scheduling contention of starving virtual threads adds extra overhead.
- Virtual thread pool “stuck”: All carrier threads are pinned, so new incoming virtual threads never get a chance to run. To the outside world, the service appears unresponsive.
- Sharp throughput degradation: QPS that used to be comfortably handled by 200 platform threads suddenly drops by half after migrating to virtual threads.
- Latency spikes under load: P99 latency explodes, often exceeding second-long timeouts.
- JVM logs: If diagnostics are enabled, you will see repeated warnings like
Pinned virtual thread.
How to Detect Virtual Thread Pinning #
In production, you cannot rely on intuition. You need systematic reconnaissance.
1. JVM Diagnostic Flag #
Start the JVM with:
-Djdk.tracePinnedThreads=full
This prints a full stack trace every time a virtual thread gets pinned, precisely identifying the pin location and the lock being held. Note that this adds some performance overhead and is best used in testing environments or for short-term production triage.
2. Thread Dump Analysis #
Capture a thread dump during the incident (e.g., jcmd <pid> Thread.dump_to_file). Look for these critical signals:
- Carrier threads (named
ForkJoinPool-1-worker-...) whose stack frames repeat persistently, stuck insidesynchronizedblocks or I/O methods. - A large number of virtual threads in
BLOCKEDstate, all waiting on the same lock. - Internal methods like
java.lang.VirtualThread.parkOnCarrierThreadnear the top of the stack, indicating the thread is currently pinned.
3. JFR (Java Flight Recorder) #
Enable JFR and focus on the jdk.VirtualThreadPinned event. It records pinning duration, the stack trace, and the involved carrier thread. Combined with JDK Mission Control, you can quickly generate hotspot lock reports and pinned region analysis. Additionally, monitoring carrier thread utilization is the single most vital health metric for virtual threads.
How to Fix Virtual Thread Pinning (Core Solutions) #
1. Replace synchronized with ReentrantLock
#
This is the most direct fix for the vast majority of pinning cases. ReentrantLock does not prevent virtual thread unmounting because its locking semantics are implemented purely in Java objects, independent of the carrier thread.
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
String data = webClient.get().uri(url).retrieve().bodyToMono(String.class).block();
processData(data);
} finally {
lock.unlock();
}
2. Never Block Inside a Lock #
Relocate blocking calls outside the lock-protected boundary:
❌ Bad:
synchronized(this) {
httpCall(); // severe pinning
updateState();
}
✅ Good:
var result = httpCall();
synchronized(this) {
updateState(result);
}
This way, the virtual thread can freely unmount during the I/O, and the lock is held only for the brief moment of updating critical state.
3. Embrace Non-Blocking APIs #
Virtual threads let you write “synchronous style” code, but you must avoid blocking on paths protected by shared locks. Recommended practices:
- Use
WebClient/HttpClientinstead ofRestTemplate. - Use async-capable JDBC drivers (like R2DBC where appropriate) or at least ensure the driver is a pure-Java implementation friendly to virtual threads.
- For file I/O, prefer Java 21’s enhanced
java.io(already optimized for virtual threads) and be cautious with older third-party file libraries.
4. Upgrade JDBC Drivers and Critical Libraries #
By 2026, most database drivers have released versions compatible with virtual threads. For instance, MySQL Connector/J 9.x and PostgreSQL JDBC 42.7+ have removed pinning-prone synchronized blocks and native calls. Audit and forcefully upgrade these dependencies.
5. Spring Boot 3.x Specific Fixes #
- Avoid
synchronizedon@Transactionalmethods. Use database optimistic locking orReentrantLockinstead. - Check custom
FactoryBeanorBeanPostProcessorimplementations for hidden synchronization logic. - Increase connection pool sizes appropriately to reduce contention amplification caused by pinning.
Advanced Fix: Carrier Thread Avoidance Design (2026 Production-Grade Optimization) #
To fundamentally eliminate pinning risk, adopt a Carrier Thread Avoidance Design strategy:
- Minimize synchronized scope: Shrink critical sections to pure in-memory operations, reducing lock hold times to microseconds.
- Isolate blocking code: Extract potentially blocking I/O into dedicated lock-free components, ensuring they never appear inside any synchronization block.
- Leverage structured concurrency: Use
StructuredTaskScopeto fork subtasks, allowing blocking to occur in independent branches that are then aggregated lock-free on the parent thread. - Real-time carrier health monitoring: Build real-time alerts for carrier thread pinning based on Micrometer metrics or custom JFR events. Trigger autoscaling or throttling immediately when pinned thread counts exceed a safe threshold.
Why Carrier Thread Starvation Becomes Catastrophic #
Virtual thread pinning does not just slow down individual requests — under sustained load, it can destabilize the entire scheduler and trigger cascading service degradation.
In Java 21, virtual threads are scheduled by a dedicated ForkJoinPool-based scheduler whose carrier thread count typically aligns with the number of available processors. Under normal conditions, when a virtual thread encounters a blocking I/O operation, the JVM quickly unmounts it from the carrier thread and releases that carrier back to the scheduler. This allows a relatively small number of carrier threads to efficiently support a massive number of virtual threads.
Pinning breaks this scheduling model.
When a virtual thread becomes pinned, it remains attached to its carrier thread for the entire duration of the blocking operation. If several requests simultaneously enter pinned execution paths — especially inside synchronized sections performing remote I/O or database access — carrier threads can become exhausted very quickly.
Once all carrier threads are occupied, the scheduler cannot mount additional runnable virtual threads, even if those threads are ready to execute and are not blocked themselves. The system enters carrier thread starvation.
At this stage, starvation tends to amplify rapidly. Runnable virtual threads accumulate in scheduler queues while external clients begin timing out and retrying requests, injecting even more pressure into the system. CPU usage can remain deceptively high because the few active carrier threads are stuck inside blocking operations, while thousands of virtual threads remain unable to make progress.
In practice, these failures are often difficult to reproduce in staging environments because they only emerge under sustained downstream latency, retry storms, or peak traffic conditions.
The ForkJoinPool work-stealing mechanism cannot effectively mitigate this situation because the problem is structural rather than computational. Work stealing can rebalance runnable tasks across worker threads, but it cannot recover carrier threads that are blocked inside pinned regions.
Industry discussions around large-scale virtual thread adoption have highlighted that starvation incidents frequently appear as mysterious “application hangs.” The JVM itself remains healthy, memory usage may appear normal, and no fatal exception is thrown — yet request processing throughput collapses because carrier threads are effectively exhausted.
Performance Comparison #
In a typical high-concurrency API scenario, the difference is stark:
| Model | Throughput (req/s) | P99 Latency | Carrier Thread Consumption |
|---|---|---|---|
| Platform Threads (200 threads) | Medium | Stable | High (200 threads) |
| Virtual Threads (no pinning) | High | Very low | Very low (~20 carriers) |
| Virtual Threads (heavy pinning) | Lower than platform | Severe spikes | Extremely high (all carriers blocked) |
Without pinning, virtual threads can often deliver 3–5× throughput on the same hardware. But once pinning takes hold, performance can drop below that of a traditional thread pool, because the additional scheduling overhead of virtual threads brings no benefit whatsoever.
Real-World Case Study #
Scenario: A fintech company’s Spring Boot 3.2 microservice handling real-time risk assessment requests.
- Traffic: average 10,000 req/s, peak 15k.
- Deployment: JDK 21 with virtual threads enabled.
- Initial setup:
Executors.newVirtualThreadPerTaskExecutor().
Incident:
30 minutes after launch, CPU spiked from 30% to 95% and request timeout rate hit 15%. A thread dump showed over 80% of carrier threads pinned inside synchronized blocks that were making downstream HTTP calls.
Root Cause:
The core risk engine used synchronized extensively to protect rule-update logic, and those blocks internally invoked a credit scoring service via RestTemplate. This triggered full-scale carrier thread pinning.
Remediation:
- Replaced all
synchronizedmethods withReentrantLock. - Moved HTTP calls outside the locked regions: fetch remote data first, then update in-memory state inside the lock.
- Swapped
RestTemplateforWebClientto further eliminate unnecessary blocking waits. - Upgraded the Oracle JDBC driver to remove legacy native locks.
Results:
- CPU usage dropped by 40%, stabilizing at 55% under peak.
- P99 latency decreased by 60%, from 2.1s to 850ms.
- The service handled the 15k peak with zero timeouts, maintaining a steady pool of fewer than 25 carrier threads.
Virtual Thread Pinning Quick Fix Checklist #
Before deploying Java 21 virtual threads to production, verify the following:
- No blocking I/O inside
synchronized - Replace long-held
synchronizedwithReentrantLock - Enable JFR
jdk.VirtualThreadPinnedmonitoring - Audit JDBC drivers for virtual-thread compatibility
- Avoid native blocking libraries where possible
- Keep critical sections extremely short
- Use
StructuredTaskScopefor concurrent subtasks - Load test under realistic peak traffic
Best Practices Summary #
Engrave these iron rules into your virtual thread adoption checklist:
- Never block inside a
synchronizedblock. - Move away from legacy blocking APIs and proactively upgrade to virtual-thread-friendly libraries.
- Embrace structured concurrency (
StructuredTaskScope) to eliminate data races by design. - Continuously monitor pinned threads in production, using
-Djdk.tracePinnedThreads=shortcombined with JFR-based alerting. - Subject every
synchronizedin your codebase to a dedicated code review. In a virtual thread project, every singlesynchronizedmust be questioned.
Further Reading #
- Deep Dive into Java Concurrency – Understand the evolution of Java’s concurrency models.
- JVM Performance & GC Tuning Guide – Tune garbage collection to work in harmony with virtual threads.
- System Design: Rate Limiter Patterns – Architect elegant rate limiting on top of a virtual thread infrastructure.
FAQ #
Q: Is Virtual Thread Pinning a bug in the JVM?
A: No. It is a defined, expected behavior designed to guarantee synchronized semantics and the safety of native code. The JVM correctly prevents unmounting to avoid breaking execution contracts.
Q: Does pinning completely break virtual threads?
A: It does not cause execution failure or data corruption, but it eliminates their scalability benefits. A pinned virtual thread behaves like a heavyweight platform thread, devastating throughput.
Q: How can I fully avoid pinning in 2026?
A: Follow two core rules: avoid blocking inside synchronized blocks, and replace all synchronized lock usage with ReentrantLock (or other java.util.concurrent locks) where blocking may occur. Combine this with mandatory library upgrades and structured concurrency for a complete pinning-free architecture.
Q: Does virtual thread pinning reduce performance?
A: Yes, drastically. Pinning forces a carrier thread to block along with the virtual thread, destroying the lightweight scheduling that gives virtual threads their scalability advantage. In severe pinning scenarios, throughput can fall below that of a traditional platform thread pool while latency spikes dramatically.
Q: Can pinning be eliminated completely?
A: In a well-maintained codebase, you can eliminate the overwhelming majority of pinning by replacing synchronized with ReentrantLock, moving blocking I/O out of locked sections, and upgrading native-heavy libraries. However, certain edge cases—such as unavoidable JNI calls or OS-level file lock interactions—may still cause transient pinning. With strict adherence to best practices, pinning can be reduced to a negligible production concern.
Q: Is synchronized always bad for virtual threads?
A: No. synchronized is perfectly safe and performs well when the critical section is extremely short, contains no blocking calls, and is not heavily contended. Pinning only becomes a problem when a synchronized block encapsulates a blocking operation (I/O, long waits). Short, pure in-memory synchronized blocks are harmless.
Q: Should I migrate to R2DBC?
A: Not necessarily. In Java 21+, virtual threads let you use traditional JDBC in a familiar blocking style while still achieving high scalability—provided the driver is virtual-thread friendly and your database calls do not occur inside a pinned region. Migrating to R2DBC introduces reactive programming complexity. If you can eliminate pinning and adopt a modern JDBC driver, sticking with JDBC is a valid and simpler choice. Reserve R2DBC for cases where you already have a reactive stack or need extreme resource efficiency beyond what virtual threads can deliver.
☕ This article is a production-grade debugging guide for Java 21 Virtual Thread scalability issues in real-world systems. Armed with these diagnostics and fixes, you can confidently reclaim the full power of lightweight concurrency in your 2026 architecture.