Definition
- Virtual thread – A lightweight thread scheduled by the JVM onto a small pool of platform carrier threads; it normally unmounts from the carrier when it blocks on I/O.
- Pinning – When a virtual thread cannot unmount and stays stuck to its carrier, consuming it and preventing other virtual threads from using it.
synchronizedcause – Blocking inside asynchronizedblock pins because the object monitor's ownership is recorded against the carrier platform thread.ReentrantLockfix – Its waiting and ownership are managed in Java-level data structures, so a virtual thread can unmount while blocked on acquisition or while holding the lock across a blocking call.- Detection – Use the JDK Flight Recorder
jdk.VirtualThreadPinnedevent to find pinned stacks in production.
Think of National Bank of India having a few senior managers (carrier threads) and thousands of tasks (virtual threads). Normally, when a task has to wait on hold for an external call, it steps aside and frees the manager to help another task. Pinning is like a task that, while holding a specific office key that only works when the original manager is present (synchronized monitor), gets stuck on a long phone call. Because the key is bound to that manager, the manager cannot leave to help anyone else and is wasted for the whole call. Switching to a ticket-based lock (ReentrantLock) lets the task keep its place in line without chaining a manager to its desk.
Understanding the Concepts
What is a virtual thread and how does unmounting work?
Virtual threads give millions of cheap threads because the JVM mounts a virtual thread onto a platform carrier thread only while it is running. When it hits a blocking operation like socket I/O, the runtime unmounts it, parks the continuation, and lets the carrier pick up another virtual thread. The carrier pool is small, typically sized to the number of CPU cores.
package com.bank.service;
import java.util.concurrent.Executors;
import java.math.BigDecimal;
public class VirtualThreadTransferService {
public void processTransfers() {
// Each transfer gets its own virtual thread
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
final int txId = i;
executor.submit(() -> {
// When this blocks on DB I/O, the virtual
// thread unmounts and frees the carrier
BigDecimal balance = loadBalance("SAV" + txId);
System.out.println("Balance: " + balance);
});
}
}
}
private BigDecimal loadBalance(String accountId) {
// Simulated blocking DB call
try { Thread.sleep(10); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return new BigDecimal("50000.00");
}
}
How does pinning happen with synchronized?
Pinning occurs when a virtual thread is inside a synchronized block or method and then performs a blocking operation. The object monitor's ownership is recorded against the carrier platform thread, so the runtime cannot cleanly move the virtual thread off that carrier while it owns the monitor.
package com.bank.service;
import java.math.BigDecimal;
public class PinnedLedgerService {
private final Object ledgerLock = new Object();
// BAD: synchronized + blocking I/O = PINNING
// The carrier thread is held hostage during loadBalance()
public BigDecimal getBalancePinned(String accountId) {
synchronized (ledgerLock) {
// This blocks on I/O while holding the monitor
// Virtual thread is PINNED to the carrier
return loadFromDatabase(accountId);
}
}
private BigDecimal loadFromDatabase(String accountId) {
try { Thread.sleep(50); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return new BigDecimal("75000.00");
}
}
Why does pinning hurt scalability?
With many virtual threads pinning at once, you exhaust the small carrier pool (typically one carrier per CPU core). When there are no free carriers left, other virtual threads cannot run even though they are ready. Your supposedly massively concurrent service grinds to a halt.
Carrier Pool (4 cores = 4 carriers)
Carrier-1: PINNED (vthread-1 in synchronized + DB call)
Carrier-2: PINNED (vthread-2 in synchronized + DB call)
Carrier-3: PINNED (vthread-3 in synchronized + DB call)
Carrier-4: PINNED (vthread-4 in synchronized + DB call)
9,996 virtual threads waiting... no carrier available!
How does ReentrantLock fix pinning?
A ReentrantLock manages its waiting and ownership in Java-level data structures rather than being bound to the carrier's monitor. A virtual thread can unmount while blocked on lock acquisition and can unmount during a blocking call made while holding the lock, freeing the carrier.
package com.bank.service;
import java.math.BigDecimal;
import java.util.concurrent.locks.ReentrantLock;
public class UnpinnedLedgerService {
private final ReentrantLock ledgerLock = new ReentrantLock();
// GOOD: ReentrantLock allows unmounting
// The carrier is freed during loadFromDatabase()
public BigDecimal getBalanceUnpinned(String accountId) {
ledgerLock.lock();
try {
// Virtual thread can unmount during this blocking call
// Carrier is free to serve other virtual threads
return loadFromDatabase(accountId);
} finally {
ledgerLock.unlock();
}
}
private BigDecimal loadFromDatabase(String accountId) {
try { Thread.sleep(50); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return new BigDecimal("75000.00");
}
}
How is synchronized different from ReentrantLock for virtual threads? (Comparison)
| Feature | synchronized | ReentrantLock |
|---|---|---|
| Pinning | Yes, pins carrier thread | No, allows unmounting |
| Monitor ownership | Tied to carrier platform thread | Java-level data structure |
| Virtual thread unmount | Blocked while holding monitor | Can unmount freely |
| Try-lock support | No | Yes, tryLock(timeout) |
| Condition variables | wait()/notify() only | Multiple Condition objects |
| Code pattern | synchronized (obj) { ... } | lock(); try { ... } finally { unlock(); } |
How do you detect pinning?
Use the JDK Flight Recorder jdk.VirtualThreadPinned event or run with the diagnostic flag that reports pinned stacks.
# Run with Flight Recorder to capture pinning events
java -XX:StartFlightRecording=filename=recording.jfr,duration=60s \
-jar bank-transfer-service.jar
# Alternative: diagnostic output for pinned stacks
java -Djdk.tracePinnedThreads=full \
-jar bank-transfer-service.jar
# Sample diagnostic output
Thread[#28,ForkJoinPool-1-worker-1,5,CarrierThreads]
com.bank.service.PinnedLedgerService.getBalancePinned(PinnedLedgerService.java:12)
<== monitors:1
What are common mistakes with virtual threads?
The first mistake is blocking inside synchronized and wondering why throughput collapses. The second is pooling virtual threads, which defeats their purpose. The third is holding any lock across slow I/O, which serializes throughput even with ReentrantLock.
// WRONG: pooling virtual threads
// ExecutorService pool = Executors.newFixedThreadPool(100);
// Virtual threads are cheap, create one per task
// CORRECT: one virtual thread per task
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> processTransfer("SAV2001", "SAV3001"));
}
// WRONG: holding lock across slow I/O
ledgerLock.lock();
try {
BigDecimal balance = slowDatabaseCall(); // serializes everything
} finally {
ledgerLock.unlock();
}
// BETTER: minimize the critical section
BigDecimal balance = slowDatabaseCall(); // no lock held during I/O
ledgerLock.lock();
try {
updateLedger(balance); // fast, in-memory update
} finally {
ledgerLock.unlock();
}
What about native methods and foreign functions?
Pinning also happens when a virtual thread executes a native method or foreign function call. The runtime cannot move the virtual thread off the carrier during native execution. This is less common in typical banking applications but relevant for services using JNI or the Foreign Function API.
What should a senior engineer know?
Keep critical sections small and do no blocking I/O while holding any lock at all, because even with ReentrantLock, holding a lock across slow I/O serializes throughput. Detect pinning early with Flight Recorder events. Never pool virtual threads because they are cheap and meant to be created per task. Recent JDK work has been reducing when synchronized pins, but the safe, portable guidance for high-scale virtual-thread code remains to prefer ReentrantLock and to avoid blocking inside synchronized.
Visual Overview
┌─────────────────────────────────────────────────┐ │ Normal Virtual Thread (unmounts) │ ├─────────────────────────────────────────────────┤ │ │ │ VThread-1 mounted on Carrier-1 │ │ │ │ │ ▼ │ │ Hits blocking I/O (DB call) │ │ │ │ │ ▼ │ │ UNMOUNTS from Carrier-1 ──→ Carrier-1 FREE │ │ (continuation parked) (picks up VThread-2)│ │ │ │ │ ▼ │ │ I/O completes ──→ Remounts on any free carrier │ └─────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────┐ │ Pinned Virtual Thread (stuck) │ ├─────────────────────────────────────────────────┤ │ │ │ VThread-1 mounted on Carrier-1 │ │ │ │ │ ▼ │ │ Enters synchronized block │ │ (monitor owned by Carrier-1) │ │ │ │ │ ▼ │ │ Hits blocking I/O inside synchronized │ │ │ │ │ ▼ │ │ CANNOT UNMOUNT ──→ Carrier-1 STUCK │ │ (monitor bound to carrier) (wasted) │ │ │ │ │ ▼ │ │ I/O completes ──→ Finally releases carrier │ └─────────────────────────────────────────────────┘
Key Takeaway
Virtual threads give massive concurrency because the JVM mounts a virtual thread onto one of a small pool of platform carrier threads only while it is actually running, and when the virtual thread hits a blocking operation like socket I/O, the runtime unmounts it and lets the carrier pick up another virtual thread. The whole model depends on virtual threads yielding their carrier whenever they wait, which is how a handful of carriers can serve millions of virtual threads.
Pinning is when a virtual thread cannot unmount and stays stuck to its carrier. The classic cause is blocking inside a synchronized block or method, because the object monitor's ownership is recorded against the carrier platform thread, so the runtime cannot move the virtual thread off that carrier while it owns the monitor. When many virtual threads do this at once, they exhaust the small carrier pool and the supposedly massively concurrent service grinds to a halt.
The fix in virtual-thread-heavy code is to replace synchronized with a ReentrantLock around any critical section that contains a blocking call. A ReentrantLock keeps its waiting and ownership state in ordinary Java data structures rather than in the carrier's intrinsic monitor, so a virtual thread can unmount while it waits to acquire the lock and can unmount during a blocking call made while holding the lock, which keeps the carrier free for other work.
In banking work at National Bank of India the transfer endpoint was moved to virtual threads expecting huge concurrency, but throughput collapsed under load. A Flight Recorder capture of the jdk.VirtualThreadPinned event pointed straight at a synchronized ledger method that made a blocking database call inside the monitor. Replacing the synchronized block with a ReentrantLock stopped the pinning and restored the expected scalability. The best practice is to keep critical sections tiny, do no blocking I/O while holding any lock at all, detect pinning early with Flight Recorder events, and never pool virtual threads.
Remember one sentence: blocking I/O inside synchronized pins the virtual thread to its carrier, so replace synchronized with ReentrantLock in virtual-thread-heavy code to let the thread unmount and keep the carrier free.
---