« All posts

Virtual Thread Pinning in Java Loom: Why Throughput Stalls

Virtual thread pinning locks carrier threads via synchronized or native calls, turning Loom apps into bounded thread pools; JDK 24's JEP 491 fixes most cases.

Java's virtual threads normally free their carrier thread the moment they block, letting a handful of OS threads serve millions of concurrent tasks. 'Pinning' breaks that model: a blocked virtual thread stays mounted on its carrier, which then does nothing useful. If a hot path pins routinely, every carrier can lock up at once, producing scheduler starvation that looks like a deadlock from the outside — as illustrated by a real incident where throughput plateaued at exactly 420 requests per second.

There are only two causes: blocking inside a synchronized block on JDK 21-23 (including inside library internals like ConcurrentHashMap.computeIfAbsent), and native frames from JNI, Foreign Function & Memory downcalls, or even static initializers. Ordinary I/O, BlockingQueue, ReentrantLock and CompletableFuture were re-plumbed for Loom and don't pin.

JDK 24's JEP 491 reassigns monitor ownership from the carrier to the virtual thread itself, eliminating synchronized-based pinning without any code changes — leaving only native-frame pins. The legacy -Djdk.tracePinnedThreads flag was removed in JDK 24, so production monitoring should rely on the jdk.VirtualThreadPinned JFR event and jcmd Thread.dump_to_file instead. Fixes include replacing synchronized with ReentrantLock, avoiding holding locks across external calls, and isolating unavoidable native blocking calls on a dedicated platform-thread executor.