skip to content

What is virtual-thread pinning in Java, and why does it matter?

level: middleimportance: must knowfreq 70%

answer

  1. Mount/unmount onto carrier threads
  2. synchronized + JNI = can't unmount
  3. Carrier stuck = lost scalability
  4. Fix: synchronized → ReentrantLock
  5. JDK 24 / JEP 491 mostly fixes synchronized pinning

basics

~20 s

Pinning is when a virtual thread can't detach from its OS carrier thread while it blocks, so it keeps that carrier busy. That wastes a scarce carrier and can stall other virtual threads, hurting throughput.

solid answer

~40 s

Virtual threads (Project Loom, stable in Java 21) are lightweight threads scheduled by the JVM onto a small pool of OS carrier threads. Normally, when a virtual thread blocks (I/O, a lock), it unmounts from its carrier so the carrier can run another virtual thread. Pinning is the exception: the virtual thread stays mounted on its carrier even while blocked. The carrier can't be reused, so you lose the main benefit of virtual threads—massive concurrency with few OS threads. The classic causes are blocking inside a synchronized block/method and blocking inside a native (JNI) call. A few pinned threads are harmless, but if many pin at once you can exhaust carriers, throttle throughput, or even deadlock. The fix is usually replacing synchronized with java.util.concurrent.locks.ReentrantLock.

go deeper

for a junior

Knows virtual threads are lightweight and that pinning means a virtual thread is stuck to a real OS thread, hurting concurrency. Knows synchronized is a common cause and ReentrantLock is the fix.

for a middle

Can explain mount/unmount onto carriers, why synchronized and JNI prevent unmounting, and the throughput/starvation consequence. Knows the ReentrantLock mitigation.

for a senior

Adds detection (jdk.tracePinnedThreads, JFR jdk.VirtualThreadPinned), distinguishes harmless brief pins from blocking-while-pinned, and reasons about carrier-pool exhaustion vs. the JVM's bounded carrier expansion.

for a principal

Frames pinning as a property of the runtime's stack-unwinding constraints, tracks the JDK 24 / JEP 491 elimination of synchronized pinning, and sets org-wide guidance (lock policy, JFR monitoring, JDK baseline) for migrating blocking code to virtual threads.

## Background: what virtual threads are A **thread** is an independent path of execution. A traditional Java thread (a *platform thread*) is a thin wrapper over an **operating-system (OS) thread**, which is expensive: each costs roughly a megabyte of stack memory and is scheduled by the OS kernel. You can realistically run only a few thousand of them. **Virtual threads** (Project Loom; stable since Java 21) are lightweight threads managed by the JVM itself, not the OS. The JVM runs them on a small pool of real OS threads called **carrier threads** (by default sized to the number of CPU cores). You can have *millions* of virtual threads. The trick that makes this work is **mount/unmount**. To run, a virtual thread is *mounted* onto a carrier (it borrows that OS thread to execute). When the virtual thread does something that blocks—reading from a socket, waiting on a lock, sleeping—the JVM *unmounts* it: it saves the virtual thread's stack to the heap and frees the carrier so the carrier can immediately run a different virtual thread. When the blocking operation completes, the virtual thread is *remounted* (possibly on a different carrier) and continues. This is why a handful of carriers can serve a huge number of blocking virtual threads: blocked ones aren't holding carriers. ## What pinning is **Pinning** is when a virtual thread *cannot* unmount, so it stays *pinned* to its carrier even while it blocks. The carrier is stuck and cannot run any other virtual thread until the pinned one unblocks. In effect, that carrier degrades to behaving like an old-style platform thread. Why can't it unmount? Unmounting requires the JVM to unwind and stash the virtual thread's Java stack. In two situations the runtime cannot safely do that: 1. **Inside a `synchronized` block or method** (in Java versions before the fix, see below). The `synchronized` monitor is tied to the OS thread that acquired it, so the virtual thread must keep that exact carrier. 2. **Inside a native method frame (JNI)**—a call into C/C++ code via the Java Native Interface. The JVM has no portable way to unmount across a native frame. ## Why it matters The whole value proposition of virtual threads is *scalability of blocking code*: write simple blocking code, get high concurrency cheaply. Pinning silently defeats that. If many virtual threads pin simultaneously, you can: - **Starve the carrier pool**: every carrier is held by a pinned thread, so runnable virtual threads can't get scheduled and throughput collapses. - **Deadlock**: if all carriers are pinned waiting for something that itself needs a free carrier to proceed. (The JVM can temporarily add carriers to relieve this, but it is bounded.) A *brief* pin (a quick `synchronized` section with no blocking inside it) is harmless—pinning only hurts when the pinned thread *blocks* while pinned. ## Detecting pinning - **`-Djdk.tracePinnedThreads=full|short`** (a JVM flag): in Java 21/22 this prints a stack trace whenever a thread blocks while pinned, so you can see *where*. Note this flag is **deprecated/removed in newer JDKs** in favor of JFR. - **JDK Flight Recorder (JFR)**: emits a `jdk.VirtualThreadPinned` event (with a configurable threshold) you can capture in a `.jfr` recording and inspect in JDK Mission Control. This is the modern, low-overhead detector. ## Mitigation The standard fix is to **replace `synchronized` with `java.util.concurrent.locks.ReentrantLock`**. A `ReentrantLock` is implemented on top of the JVM's parking machinery, so when a virtual thread waits on it, it *unmounts* normally—no pinning. Other mitigations: keep `synchronized` regions tiny and do no blocking inside them; push blocking work outside the lock; or avoid the lock entirely with concurrent collections or `Atomic*` classes. ## Important version note **JDK 24 (JEP 491) largely eliminates `synchronized` pinning**: the runtime was reworked so a virtual thread can unmount even while holding a monitor. So on modern JDKs, `synchronized` no longer pins (native/JNI frames still can). On Java 21–23, the `synchronized`→`ReentrantLock` advice is real and important. Always state which JDK you mean.

  • Does a synchronized block always cause harmful pinning?
    No. Pinning only hurts if the virtual thread blocks while inside the synchronized region. A quick, non-blocking critical section pins only momentarily and is harmless. And on JDK 24+ (JEP 491) synchronized no longer pins at all.
  • Why does ReentrantLock avoid pinning when synchronized does not?
    ReentrantLock is built on the JVM's LockSupport.park parking mechanism, which integrates with the virtual-thread scheduler so the thread can unmount while waiting. The synchronized monitor (pre-JEP 491) is tied to the carrier OS thread, preventing unmount.

saying these in an interview costs you the question

  • Saying any synchronized block pins regardless of JDK version (JEP 491 in JDK 24 changed this)
  • Claiming pinning is a deadlock by itself—a short pin with no blocking inside is harmless
  • Confusing carrier threads with virtual threads, or thinking each virtual thread is an OS thread
  • Saying ReentrantLock is faster than synchronized in general—the point is it lets the VT unmount, not raw speed

context