Virtual Thread Pinning — Causes, Detection, Fixes
Fast recall
Quick revision
Core answer
LockSupport.park under the hood, which does unmount correctly; (2) minimize native calls that block. Detection: JFR events `jdk.VirtualThreadPinned` or the JVM flag `-Djdk.tracePinnedThreads=full`. Future Java versions (JEP 491, post-21) plan to eliminate pinning on synchronized entirely, but until then audit your codebase before migrating to virtual threads at scale.Whether the candidate knows pinning exists, what causes it (synchronized, native frames), its impact (carrier starvation), how to detect (JFR, -Djdk.tracePinnedThreads), and how to fix (ReentrantLock)
Assuming virtual threads always unmount on blocking I/O. Not knowing that `synchronized` pins. Not adding pinning detection before production rollout
Mention JEP 491 to eventually remove synchronized pinning; explain the 'carrier exhaustion' failure mode (not slow but deadlock-like stall); and demonstrate how to detect with `-Djdk.tracePinnedThreads=full`
Interview-ready guide
Interview answer
Pinning is when a virtual thread cannot be unmounted from its carrier, defeating Loom's scaling.
Two causes
- `synchronized` blocks/methods — the JVM monitor is tied to the OS thread; the continuation can't capture the stack while the monitor is held
- Native (JNI) frames — native code doesn't participate in Loom's continuation mechanism
Why it's dangerous
- If a pinned virtual thread hits blocking I/O, the carrier sits idle waiting with it
- Under load, pinned threads can equal or exceed carrier count
- Result: carrier exhaustion — no virtual thread can run, system appears frozen, CPU at 0%
- Silent until it hits scale — light load hides it, production load exposes it
Classic bug pattern
synchronizedmethod that calls JDBC- Platform threads: serialized on monitor, 200ms waits, no big deal
- Virtual threads: carrier pinned during the 200ms JDBC; 8 concurrent = all carriers stalled
- Server appears hung;
jstackshows all carriers waiting on DB
The fix — ReentrantLock
ReentrantLockusesLockSupport.park()internally- LockSupport is Loom-aware — triggers continuation yield
- Virtual thread unmounts cleanly even while holding the lock
- Migration: wrap with
lock.lock()/finally { lock.unlock(); }
Detection
- Dev / staging:
-Djdk.tracePinnedThreads=fullprints stack traces when pinning occurs - Production: JFR event
jdk.VirtualThreadPinned— low overhead, works with any JFR tooling - Custom monitoring: stream JFR to observability platform, alert on sustained pinning
Common pinning sources to audit
- Explicit
synchronizedin application code - Old JDBC drivers (upgrade to latest)
PrintStream.println,Random(useThreadLocalRandom)- Old logger versions — upgrade Logback/Log4j2
Object.wait/notify— replace withBlockingQueue,Semaphore,CountDownLatch- Class static initializers doing I/O
What's fine
- Short in-memory
synchronizedblocks (< 1 μs) — pinning duration is trivial - Occasional pinning at startup (classloading)
- Non-hot-path synchronized where upgrade cost exceeds benefit
Future — JEP 491
- Proposed post-Java 21
- Reworks
synchronizedmonitors to be Loom-friendly - Most common pinning source will disappear
- Native-frame pinning is fundamental and will remain
Workaround for unfixable pinning
- Isolate onto a dedicated platform-thread pool
- Increase carrier parallelism (
jdk.virtualThreadScheduler.parallelism=200) — band-aid, not fix - Upgrade libraries to Loom-aware versions
Recommendation: Before rolling virtual threads to production, audit every `synchronized` block on hot paths that does blocking I/O and migrate them to ReentrantLock. Turn on `-Djdk.tracePinnedThreads=full` in staging under load-test traffic — every pinning stack trace is a potential production stall. In production, capture `jdk.VirtualThreadPinned` JFR events as a continuous SLI with alerts on sustained pinning. Non-hot-path synchronized code can stay as-is. Watch for JEP 491 in future LTS releases — it will retire most of this audit burden. Pinning is the #1 reason teams roll back from Loom; treat it as a first-class operational concern, not an afterthought.
Virtual Thread Pinning — Causes, Detection, Fixes
Direct Answer
Pinning is when a virtual thread cannot be unmounted from its carrier — usually because it's inside a synchronized block or executing a native (JNI) frame. While pinned, any blocking I/O holds the carrier thread, defeating the whole point of Loom. If many virtual threads pin simultaneously on blocking I/O, carriers get exhausted: no virtual thread can run because there's no free carrier. The two main fixes: (1) replace synchronized with ReentrantLock — it uses LockSupport.park under the hood, which does unmount correctly; (2) minimize native calls that block. Detection: JFR events jdk.VirtualThreadPinned or the JVM flag -Djdk.tracePinnedThreads=full. Future Java versions (JEP 491, post-21) plan to eliminate pinning on synchronized entirely, but until then audit your codebase before migrating to virtual threads at scale.
Common Mistakes & Pitfalls
Assuming virtual threads always unmount on blocking I/O. Not knowing that synchronized pins. Not adding pinning detection before production rollout
Check your understanding
Answer these without looking back at the article.
- Why does `synchronized` pin but `ReentrantLock` doesn't?
- How would you detect pinning in a running production service?
- If a library has internal `synchronized` you can't change, how do you work around pinning?
- What is JEP 491 and how does it change the pinning story?
- Does `synchronized` on a fast in-memory operation cause real problems?
Related interview prep categories
2Looking for a structured roadmap?
Every module on this page is also part of the full Java backend interview track.