How did JEP 491 change synchronized pinning, and what should still concern an architect adopting virtual threads at scale?
answer
- JEP 491 = JDK 24, synchronized no longer pins
- Monitor ownership decoupled from carrier
- Native/JNI still pins → isolate on platform threads
- Carrier pool ~= CPU count; VTs aren't CPU parallelism
- Semaphore to limit, not virtual-thread pools; scoped values > ThreadLocal
basics
~20 sJEP 491 (JDK 24) reworked the runtime so a virtual thread can unmount even while holding a synchronized monitor, so synchronized no longer pins. Native/JNI blocking can still pin, and you must still watch carrier-pool sizing and thread-locals at scale.
solid answer
~50 sBefore JDK 24, blocking inside `synchronized` pinned the virtual thread to its carrier, which was the dominant real-world pitfall and forced widespread `synchronized`→`ReentrantLock` refactors. **JEP 491 (JDK 24)** reworked the monitor implementation so the runtime can unmount a virtual thread that is *inside* a `synchronized` block or holding a monitor; synchronized-induced pinning is essentially gone. What remains for an architect: (1) **native/JNI (and FFM) frames still pin**, so blocking native libraries/drivers need isolation onto platform threads; (2) **carrier-pool sizing**—the default parallelism equals CPU count, and CPU-bound or still-pinning work can saturate it; (3) **per-task overhead**—ThreadLocal usage and pooling anti-patterns (don't pool virtual threads; use a `Semaphore` to limit concurrency); (4) **JDK-version skew**—if any service still runs JDK 21–23, the synchronized advice and detection (tracePinnedThreads/JFR) still apply there. So the guidance evolved from 'rewrite synchronized' to 'baseline JDK 24+, isolate native blocking, monitor with JFR, and size/limit correctly.'
go deeper
Knows that a newer JDK (24, JEP 491) made synchronized stop pinning, so older 'use ReentrantLock' advice is less necessary.
Can state that JEP 491 lets a virtual thread unmount while holding a monitor and that native/JNI can still pin.
Explains the monitor-ownership decoupling, the residual native-pinning case and its platform-thread isolation, and correct concurrency limiting with a Semaphore.
Owns the adoption strategy across a fleet: JDK baseline and version-skew handling, native-blocking isolation patterns, carrier sizing vs CPU-bound work, scoped values over ThreadLocal, and continuous JFR observability—framing pinning as one operational concern among several.
## The arc of the problem When virtual threads were finalized in **JDK 21 (JEP 444)**, the single biggest operational gotcha was **`synchronized` pinning**: a virtual thread that blocked while holding a monitor could not unmount, so it held its carrier OS thread. In real systems—where `synchronized` is pervasive in application code, libraries, and even parts of the JDK—this meant adopters had to hunt down blocking `synchronized` regions and convert them to `ReentrantLock`, and run with `-Djdk.tracePinnedThreads` to find them. It was the dominant adoption tax. ## What JEP 491 changed (JDK 24) **JEP 491, 'Synchronize Virtual Threads without Pinning'** (delivered in **JDK 24**), reworked the JVM's object-monitor implementation so that a virtual thread can **release its carrier (unmount) even while it owns a monitor** or is blocked entering a `synchronized` block. The monitor ownership is decoupled from the specific carrier OS thread. The practical effect: **`synchronized` no longer pins.** This removes the need for most `synchronized`→`ReentrantLock` refactors and makes existing, idiomatic Java code scale on virtual threads without modification. This is a runtime change, not an API change—code is unaffected; you just get the better behavior by running on JDK 24+. ## What an architect must STILL care about JEP 491 is not 'pinning is solved forever.' The remaining concerns: **1. Native/JNI and FFM pinning persists.** Blocking inside a native method frame (JNI) or a Foreign Function & Memory downcall still cannot unmount. Blocking native database drivers, native crypto, or other C libraries can pin. Mitigation: run those calls on a **dedicated, bounded pool of platform threads** and bridge results, so native blocking never starves the virtual-thread carriers. **2. Carrier-pool sizing and CPU-bound work.** The carrier pool's parallelism defaults to the number of CPU cores (`jdk.virtualThreadScheduler.parallelism`). Virtual threads excel at *I/O-bound* concurrency; they do **not** add CPU parallelism. CPU-heavy tasks, or any residual pinning, can saturate the small pool. Architects must keep CPU-bound work off the virtual-thread path or size deliberately. **3. Don't pool virtual threads; bound with a Semaphore.** A classic anti-pattern is treating virtual threads like a limited resource and pooling them. Instead create one per task (`Executors.newVirtualThreadPerTaskExecutor`) and limit *concurrency* (e.g., to a downstream's capacity) with a **`Semaphore`**, which doesn't pin. **4. ThreadLocal and memory.** With millions of virtual threads, heavy `ThreadLocal` usage multiplies memory. Prefer the newer **scoped values** (immutable, inheritable, cheaper) for per-task context. **5. JDK-version skew across the fleet.** If any service still runs **JDK 21–23**, synchronized pinning is live there: keep the `ReentrantLock` guidance, `jdk.tracePinnedThreads`/JFR detection, and a migration plan to JDK 24+. Heterogeneous fleets mean the 'right answer' is version-dependent. **6. Observability stays mandatory.** Even post-JEP-491, keep **JFR `jdk.VirtualThreadPinned`** (now mostly catching native pins) and `jdk.VirtualThreadSubmitFailed` enabled to catch the residual cases and carrier stress. ## How the recommended posture evolved - **JDK 21–23 era:** 'Audit and refactor blocking `synchronized` to `ReentrantLock`; trace pinned threads.' - **JDK 24+ era:** 'Baseline on JDK 24+, so synchronized just works; focus remaining effort on isolating native/JNI blocking, sizing/limiting concurrency correctly (Semaphore, not pools), preferring scoped values over ThreadLocal, and monitoring with JFR.' ## What a strong principal-level answer states It narrates the JDK 21→24 evolution, explains *mechanically* that JEP 491 decoupled monitor ownership from the carrier, then enumerates the residual architecture concerns (native pinning, carrier sizing/CPU-bound, no virtual-thread pooling, ThreadLocal vs scoped values, fleet version skew, ongoing JFR observability)—i.e., that adoption guidance is now operational/architectural rather than a code-rewrite exercise.
- After JEP 491, is there any reason to still prefer ReentrantLock over synchronized?Yes—for its richer features: tryLock with timeout, lockInterruptibly, fairness, and multiple Conditions. Not for pinning anymore. For plain mutual exclusion, idiomatic synchronized is fine on JDK 24+.
- Why limit concurrency with a Semaphore instead of a fixed-size virtual-thread pool?Virtual threads are cheap and meant to be one-per-task; pooling them reintroduces the resource-contention they were designed to remove. A Semaphore caps how many run a protected section concurrently (e.g., to match a downstream's capacity) without pinning or limiting thread creation.
saying these in an interview costs you the question
- Claiming JEP 491 eliminates ALL pinning, including native/JNI
- Saying virtual threads add CPU parallelism—they help I/O-bound concurrency, not CPU work
- Advising to pool virtual threads to limit concurrency instead of a Semaphore
- Ignoring fleet version skew—JDK 21–23 services still pin on synchronized
- Treating ThreadLocal as free at virtual-thread scale