Definition
- Pinning – the condition where a virtual thread is stuck on its carrier platform thread and cannot be unmounted when it blocks on I/O.
- Cause 1: synchronized – in Java 21 the JVM monitor implementation requires the thread to stay mounted while holding a monitor lock, so any blocking I/O inside a
synchronizedblock pins the carrier. - Cause 2: JNI – native code executed through JNI cannot be suspended, so the virtual thread remains mounted for the entire native call.
- Impact – a pinned carrier cannot serve other virtual threads, reducing the effective carrier pool size and degrading throughput.
- Fix – replace
synchronizedwithReentrantLockfor code paths that contain blocking I/O;ReentrantLocksupports virtual thread unmounting.
A bank teller (carrier) handling numbered tokens (virtual threads) can switch between waiting customers. But if a customer insists on holding the teller's hand during a phone call (synchronized block with blocking I/O), the teller is pinned and cannot serve anyone else. The fix is a walkie-talkie system (ReentrantLock) where the teller hands the customer a walkie-talkie and moves on, returning when the call finishes.
Understanding the Concepts
What is virtual thread pinning?
Pinning happens when a virtual thread cannot be unmounted from its carrier OS thread while it is blocked. Normally, when a virtual thread blocks on I/O, the JVM detaches it from the carrier and mounts another runnable virtual thread. Pinning prevents this detachment, so the carrier sits idle (or rather, blocked) for the duration of the I/O call.
In Java 21, two situations cause pinning. The first is blocking inside a synchronized block or method. The JVM's monitor (intrinsic lock) implementation requires the thread to stay mounted while holding the lock. The second is a JNI native call, where native code cannot be suspended by the JVM scheduler.
Why does pinning matter in production?
The JVM maintains a small pool of carrier threads (typically one per CPU core). If several virtual threads are pinned simultaneously, the carrier pool shrinks. When all carriers are pinned, no other virtual thread can run, and the application appears to hang.
Consider a banking transfer service with 8 carriers and 10,000 virtual threads. If 8 virtual threads enter a synchronized method that makes a database call, all 8 carriers are pinned. The remaining 9,992 virtual threads are starved until one of the database calls completes.
package com.bank.service;
import java.math.BigDecimal;
public class AccountService {
// WRONG: synchronized + blocking I/O = pinning
public synchronized BigDecimal getBalance(String accountId) {
// This database call blocks the carrier for the entire query
return jdbcTemplate.queryForObject(
"SELECT balance FROM accounts WHERE id = ?",
BigDecimal.class, accountId);
}
}
How do you fix pinning?
Replace synchronized with java.util.concurrent.locks.ReentrantLock. A virtual thread blocked on a ReentrantLock CAN be unmounted from its carrier.
package com.bank.service;
import java.math.BigDecimal;
import java.util.concurrent.locks.ReentrantLock;
public class AccountService {
private final ReentrantLock lock = new ReentrantLock();
// CORRECT: ReentrantLock allows unmounting
public BigDecimal getBalance(String accountId) {
lock.lock();
try {
return jdbcTemplate.queryForObject(
"SELECT balance FROM accounts WHERE id = ?",
BigDecimal.class, accountId);
} finally {
lock.unlock();
}
}
}
Before (synchronized): 8 carriers pinned, 9992 VTs starved
After (ReentrantLock): 8 carriers free to serve other VTs during DB call
How has pinning changed across Java versions?
| Version | synchronized Behavior | JNI Behavior |
|---|---|---|
| Java 21 | Pins the carrier | Pins the carrier |
| Java 22-23 | Pins the carrier | Pins the carrier |
| Java 24 (JEP 491) | No longer pins | Pins the carrier |
Java 24 reimplements monitors so that a virtual thread holding a monitor can unmount when it blocks. This removes the most common source of pinning. However, JNI calls still pin in all versions.
Even after Java 24, ReentrantLock remains preferred for new code because it offers features synchronized lacks: tryLock() with timeouts, interruptible locking, and multiple Condition objects.
How do you detect pinning?
Two detection methods are available.
The first is the JVM flag -Djdk.tracePinnedThreads=short (or =full). When a virtual thread is pinned, the JVM prints a stack trace to stderr showing where the pinning occurred.
java -Djdk.tracePinnedThreads=short -jar bank-service.jar
Thread[#42,VirtualThread[#42]/runnable,5,CarrierThreads]
com.bank.service.AccountService.getBalance(AccountService.java:12)
<== monitors:1
The second is JDK Flight Recorder (JFR). The event jdk.VirtualThreadPinned is recorded whenever a virtual thread is pinned longer than a threshold. This is production-safe and does not require JVM restarts.
java -XX:StartFlightRecording=filename=recording.jfr -jar bank-service.jar
# Then analyze with JDK Mission Control
What are the rules for avoiding pinning?
- Never put blocking I/O (JDBC, HTTP, file read, Thread.sleep) inside a
synchronizedblock. - Use
ReentrantLockwhen you need mutual exclusion around code that may block. - Keep
synchronizedsections short and CPU-only (in-memory operations). - Review third-party libraries for
synchronizedon I/O paths (many older libraries use synchronized internally). - Use JFR monitoring in production to detect pinning from library code.
What are common mistakes?
// WRONG: synchronized on the entire method
public synchronized void processTransfer(String from, String to, BigDecimal amount) {
BigDecimal balance = fetchBalance(from); // I/O: pins carrier
debit(from, amount); // I/O: still pinned
credit(to, amount); // I/O: still pinned
}
// CORRECT: No lock around I/O, lock only the critical in-memory section
private final ReentrantLock balanceLock = new ReentrantLock();
public void processTransfer(String from, String to, BigDecimal amount) {
BigDecimal balance = fetchBalance(from); // I/O: no lock, VT unmounts freely
balanceLock.lock();
try {
validateBalance(balance, amount); // in-memory: fast
} finally {
balanceLock.unlock();
}
debit(from, amount); // I/O: no lock
credit(to, amount); // I/O: no lock
}
What should a senior engineer know?
- Pinning detection should be part of the CI pipeline: run integration tests with
-Djdk.tracePinnedThreads=shortand fail the build if pinning is logged. - JDBC drivers (HikariCP connection acquisition) previously used
synchronized. Newer versions have been updated for virtual thread compatibility. - Spring Boot 3.2+ with
spring.threads.virtual.enabled=trueworks with virtual threads, but you must verify that all middleware and connection pools are pinning-free. - The carrier pool can be oversized with
-Djdk.virtualThreadScheduler.parallelism=Nas a temporary workaround while fixing pinning, but this wastes memory.
What are common interview follow-up questions?
- Why not just use ReentrantLock everywhere? (synchronized is simpler, auto-released, and sufficient when there is no blocking I/O inside the critical section.)
- Does pinning cause deadlock? (No, pinning causes carrier starvation, not deadlock. The pinned virtual thread will eventually complete its I/O.)
- Can you detect pinning at compile time? (No. It requires runtime analysis because the JVM must know which code path includes blocking I/O.)
Visual Overview
Normal Virtual Thread (no pinning): ┌──────┐ ┌──────────┐ ┌──────┐ │ VT-1 │────▶│ Carrier-1│────▶│ I/O │ │ │ │ │ │block │ │ │◀────│ unmount │ │ │ │parked│ │ │ │ │ │ │ │ mount │ │ │ │ VT-5 │────▶│ Carrier-1│ │ │ └──────┘ └──────────┘ └──────┘ Carrier-1 serves VT-5 while VT-1 waits. Pinned Virtual Thread (synchronized + I/O): ┌──────┐ ┌──────────┐ ┌──────┐ │ VT-1 │────▶│ Carrier-1│────▶│ I/O │ │synch │ │ PINNED │ │block │ │ │ │ cannot │ │ │ │ │ │ unmount │ │ │ │ VT-5 │ │ │ │ │ │WAIT │ │ blocked │ │ │ └──────┘ └──────────┘ └──────┘ Carrier-1 is stuck. VT-5 must wait for another carrier.
Key Takeaway
Thread pinning is the primary obstacle to virtual thread performance. It occurs when a virtual thread cannot unmount from its carrier during a blocking operation, most commonly because it is inside a synchronized block that contains I/O. When pinned, the carrier thread is blocked and unavailable to serve other virtual threads, shrinking the effective carrier pool.
The fix is straightforward: replace synchronized with ReentrantLock on any code path that contains blocking I/O. A virtual thread blocked while waiting for a ReentrantLock can be unmounted from its carrier, so the carrier remains available. Keep synchronized for short, CPU-only critical sections where it remains simpler and appropriate.
Java 24 (JEP 491) removes the synchronized pinning limitation entirely by reimplementing monitors to support virtual thread unmounting. However, ReentrantLock remains preferred for new code because it offers tryLock with timeouts, interruptible locking, and multiple Condition objects. JNI calls still cause pinning in all versions.
Detect pinning with -Djdk.tracePinnedThreads=short during development and with JFR's jdk.VirtualThreadPinned event in production. In a banking system, a TransferService that guards account updates with synchronized and makes a blocking database call inside it pins carriers under load. Switching to ReentrantLock around only the in-memory validation, with I/O calls outside any lock, restores full virtual thread scalability.
Remember one sentence: pinning happens when synchronized plus blocking I/O prevents a virtual thread from unmounting, and the fix is ReentrantLock.
---