How do you mitigate virtual-thread pinning, and why does replacing synchronized with ReentrantLock work?
answer
- synchronized → ReentrantLock with try/finally
- ReentrantLock parks via LockSupport → unmounts
- unlock() is mandatory (no auto-release)
- Shrink critical section; no blocking while held
- Isolate native/JNI blocking on platform threads
basics
~20 sReplace blocking synchronized blocks/methods with java.util.concurrent.locks.ReentrantLock using lock()/unlock() in a try/finally. ReentrantLock lets the virtual thread unmount while it waits, so the carrier stays free. Also keep critical sections short and avoid blocking inside them.
solid answer
~50 sThe primary mitigation is to convert blocking `synchronized` regions to `ReentrantLock`. A monitor (`synchronized`) is tied to the carrier OS thread, so a virtual thread blocked inside it can't unmount; `ReentrantLock` is built on `LockSupport.park`, which integrates with the virtual-thread scheduler, so a waiting virtual thread unmounts and frees its carrier. The mechanical translation is `lock.lock(); try { ... } finally { lock.unlock(); }`—and the `finally` is mandatory, because unlike `synchronized` the lock isn't auto-released. Beyond that: keep critical sections tiny and do no blocking I/O inside them; move blocking work outside the lock; prefer lock-free options (concurrent collections, `Atomic*`, `StampedLock`) where possible; and isolate unavoidable native/JNI blocking onto dedicated platform threads. On **JDK 24+ (JEP 491)**, `synchronized` no longer pins, so this refactor is mainly needed on JDK 21–23 and for the residual native-frame case.
code
java · 24 linesimport java.util.concurrent.locks.ReentrantLock;
class RateLimitedClient {
private final ReentrantLock lock = new ReentrantLock();
private final HttpClient http = HttpClient.newHttpClient();
// BAD on JDK 21-23: blocking HTTP call inside synchronized pins the carrier
// synchronized String fetchBad(URI uri) throws Exception {
// return http.send(req(uri), BodyHandlers.ofString()).body();
// }
// GOOD: ReentrantLock lets the virtual thread unmount while it waits
String fetch(URI uri) throws Exception {
lock.lock();
try {
return http.send(
HttpRequest.newBuilder(uri).build(),
HttpResponse.BodyHandlers.ofString()
).body();
} finally {
lock.unlock(); // mandatory: ReentrantLock does NOT auto-release
}
}
}go deeper
Knows the fix is to use ReentrantLock instead of synchronized, with lock()/unlock() in try/finally.
Can perform the translation correctly (mandatory finally) and explain that ReentrantLock lets the virtual thread unmount while waiting.
Explains the LockSupport.park mechanism, applies secondary mitigations (shrink sections, lock-free structures, Semaphore for limits, isolate native blocking), and knows when NOT to refactor.
Defines org-wide guidance: JDK baseline (JEP 491), lock-policy and dependency vetting, patterns for isolating native blocking, and avoids needless churn while ensuring native and library hot-spots are handled.
## The goal Mitigating pinning means letting a blocked virtual thread **unmount** so its carrier OS thread is freed for other work. Since the two causes are blocking-inside-`synchronized` and blocking-inside-native-frames, the mitigations target those. ## Primary fix: `synchronized` → `ReentrantLock` **Why `synchronized` pins (recap).** `synchronized` acquires an object's intrinsic *monitor*. The monitor's owner is recorded against the OS thread that holds it. To unmount a virtual thread you'd have to hand the monitor to whatever carrier remounts it later; the pre-JDK-24 runtime can't do that, so the thread is pinned to its carrier while it owns the monitor and blocks. **Why `ReentrantLock` doesn't pin.** `java.util.concurrent.locks.ReentrantLock` is a *library* lock built on `AbstractQueuedSynchronizer`, which waits via **`LockSupport.park`**. `park` is exactly the primitive the virtual-thread runtime hooks into to unmount: when a virtual thread parks, the JVM saves its stack and frees the carrier. So a virtual thread waiting for a `ReentrantLock` unmounts cleanly—no pinning. **The mechanical translation** (and its gotcha): ``` // before synchronized (obj) { doWork(); } // after lock.lock(); try { doWork(); } finally { lock.unlock(); // MANDATORY: synchronized auto-releases, ReentrantLock does not } ``` The `try/finally` is not optional: `synchronized` releases the monitor automatically on every exit path (including exceptions), but `ReentrantLock` only releases when you call `unlock()`. Forgetting `finally` leaks the lock and can deadlock the application. Use `lock.lockInterruptibly()` or `tryLock(timeout)` when you need cancellation/timeouts—another capability `synchronized` lacks. ## Secondary mitigations 1. **Shrink the critical section.** Pinning only hurts when you *block while pinned*. If the `synchronized` region does no blocking and is brief, the pin is negligible—so move any blocking I/O *outside* the lock. Often the cleanest fix is restructuring, not switching lock types. 2. **Prefer lock-free / specialized concurrency.** `ConcurrentHashMap`, `CopyOnWriteArrayList`, `Atomic*`, `LongAdder`, `Semaphore`, `StampedLock`—these avoid coarse monitors entirely and don't pin. 3. **Isolate unavoidable native blocking.** JEP 491 doesn't fix native/JNI pinning. If a library must do blocking native calls, run those tasks on a **dedicated pool of platform threads** (e.g., a bounded `Executors.newFixedThreadPool`) and bridge results back, so they never starve your virtual-thread carriers. 4. **Bound concurrency with a `Semaphore`, not a thread pool.** With virtual threads you don't pool threads to limit concurrency; you use a `Semaphore`—which doesn't pin. 5. **Upgrade the JDK.** Moving to **JDK 24+ (JEP 491)** eliminates `synchronized` pinning outright, reducing how much refactoring you need (native still requires care). ## Pitfalls to avoid - **Don't** blindly replace *every* `synchronized` with `ReentrantLock`—only the ones that block while held matter; needless churn adds the `unlock()`-leak risk. - **Don't** assume `ReentrantLock` is 'faster.' It isn't about speed; it's about unmount-ability. For tiny uncontended sections `synchronized` is fine and may be cheaper. - **Don't** forget reentrancy semantics: both `synchronized` and `ReentrantLock` are reentrant, but a `ReentrantLock` must be unlocked the same number of times it was locked. ## What a strong answer states Replace *blocking* `synchronized` with `ReentrantLock` (with `try/finally`), explain park-based unmounting as the reason, add the secondary mitigations (shrink sections, lock-free structures, isolate native blocking on platform threads, Semaphore for concurrency limits), and note that JDK 24/JEP 491 makes the `synchronized` part largely unnecessary going forward.
- Why is the finally block mandatory with ReentrantLock but not with synchronized?synchronized releases its monitor automatically on every exit, including exceptions. ReentrantLock is an explicit API: it only releases when you call unlock(). Putting unlock() in finally guarantees release even if the body throws, preventing a leaked lock and deadlock.
- A native database driver blocks inside a JNI call and pins. ReentrantLock won't help—what do you do?Run those native blocking calls on a dedicated bounded pool of platform threads and bridge results back to the virtual-thread code, so the native blocking never holds your virtual-thread carriers. JEP 491 doesn't address native-frame pinning.
saying these in an interview costs you the question
- Forgetting try/finally around unlock()—leaks the lock and risks deadlock
- Claiming ReentrantLock is generally faster than synchronized; the point is unmount-ability
- Replacing every synchronized blindly instead of only blocking ones
- Thinking ReentrantLock fixes native/JNI pinning (it doesn't)
- Pooling virtual threads to limit concurrency instead of using a Semaphore