← Back to list

JEP Translated: Virtual Threads No Longer Pin

Java 24 finally lets virtual threads unmount inside synchronized methods, ending the pinning problem that forced library authors onto…

Dmitriy Kopylenko in All things software · 2026-05-05 13:06 · 0 claps · 6.9 min read
#java #jep #software-development #software-engineering #software-architecture
Open on Medium ↗
Wiki topics: LIT · Literature & Writing 📚 · Books & Reading 🏛️ · Architecture

JEP Translated: Virtual Threads No Longer Pin

Java 24 finally lets virtual threads unmount inside synchronized methods, ending the pinning problem that forced library authors onto ReentrantLock.

You moved your service to virtual threads in JDK 21, expected the throughput numbers to climb, and they didn’t. You looked at JFR, found a fleet of jdk.VirtualThreadPinned events, and traced them down to a synchronized method on a hot path - usually inside a library you don't own. Maybe a JDBC driver. Maybe a logging adapter. Maybe Apache HttpClient. The recommendation at the time was "ask the library to migrate to ReentrantLock," which most of them eventually did, slowly.

JEP 491, delivered in JDK 24, makes that whole class of advice obsolete. synchronized no longer pins virtual threads to their carriers. You can use the keyword again, the libraries you depend on don't need to be rewritten, and the second-most-confusing thing about virtual threads in JDK 21 just stopped being a concern.

Part of the JEP Translated series, where I run Java JEPs through a Claude Code skill that turns the formal spec into something you’d actually read.

JEP 491: Synchronize Virtual Threads without Pinning (Java 24)

TL;DR

A virtual thread that blocks inside a synchronized method, statement, or Object.wait() no longer holds onto its carrier platform thread. The carrier is released back to the JDK scheduler, which can mount a different virtual thread on it. This eliminates the "I went to virtual threads and my throughput got worse" surprise that hit a lot of teams when they rolled VTs into production. No source-level change required - it's a JVM implementation update.

Status: finalized in JDK 24. Implementation-only.

Quick refresher: how virtual threads work

If you’ve used virtual threads, you can skim. Three concepts to keep in mind:

  • A virtual thread is a Thread instance scheduled by the JDK, not the OS. Cheap to create. Cheap to block.
  • A platform thread is a real OS thread. Expensive. Limited.
  • The JDK’s scheduler runs virtual threads by mounting them onto platform threads. The platform thread becomes the carrier. When the virtual thread does something blocking — I/O, mostly — it unmounts from the carrier, and the scheduler can mount a different virtual thread on that carrier in the meantime.

Mount, unmount, mount, unmount, all transparent. That’s the model. Millions of virtual threads, a few hundred carriers, no problem.

Until you hit synchronized.

The problem: synchronized pins the carrier

Take this method, lifted directly from the JEP:

synchronized byte[] getData() {
    byte[] buf = ...;
    int nread = socket.getInputStream().read(buf);    // can block here
    ...
}

Pre-JDK 24, when a virtual thread enters getData(), the JVM pins it to its carrier. If the read call inside blocks waiting for bytes, the virtual thread can't unmount - because it's pinned. And since the carrier is now stuck holding a blocked virtual thread, the carrier itself is blocked too.

You just lost a platform thread. If this happens on enough virtual threads at the same time, you run out of carriers, and the entire scheduler stalls. Best case: throughput collapse. Worst case: deadlock, because no virtual thread can make progress.

This was the dirty secret of virtual threads in JDK 21: the model was beautiful, but synchronized quietly broke it. And synchronized is everywhere. Every library written before 2023 uses it. Most code in the JDK itself uses it. The advice "audit your stack and migrate to ReentrantLock" was real but exhausting.

The deeper problem: why pinning happened in the first place

This is the part that’s easy to miss when you read the JEP at the surface. The pinning wasn’t a missing feature. It was a correctness requirement.

synchronized is defined in terms of monitors. Every Java object has a monitor; entering a synchronized block acquires that monitor; exiting releases it. The JVM has to track which thread holds the monitor so that another thread trying to enter the same synchronized block correctly blocks.

Pre-JDK 24, the JVM tracked monitor ownership by platform thread, not virtual thread. When a virtual thread acquired a monitor, the JVM recorded the carrier’s identity as the owner.

Now imagine if the virtual thread were allowed to unmount while holding the monitor. The JDK scheduler sees a free carrier and mounts a different virtual thread on it. That second virtual thread, looking at the JVM’s bookkeeping, would appear to own the monitor — because the JVM thinks the carrier owns it, and the carrier is now hosting a different virtual thread. The second VT could now release a monitor it never acquired, or call other synchronized methods on the same object. Mutual exclusion gone.

So pinning wasn’t laziness. It was the only thing keeping synchronized correct given how the JVM tracked monitor ownership. The fix in JEP 491 is to track monitor ownership by virtual thread, with bookkeeping at every mount and unmount. Once that's in place, the virtual thread can unmount safely - the JVM knows who owns the monitor regardless of which carrier the VT is mounted on right now.

What this changes for your code

Nothing at the source level. You don’t recompile. You don’t migrate. You don’t pass a flag. You upgrade to JDK 24 and the same code runs differently:

// JDK 21: this pins the carrier when the read blocks
synchronized byte[] getData() {
    byte[] buf = ...;
    int nread = socket.getInputStream().read(buf);
    ...
}

// JDK 24: identical source, but the virtual thread now unmounts cleanly
//        when the read blocks. Carrier is released to the scheduler.
synchronized byte[] getData() {
    byte[] buf = ...;
    int nread = socket.getInputStream().read(buf);
    ...
}

Object.wait() and the timed-wait variants get the same treatment. A virtual thread that calls wait() inside a synchronized block now unmounts. When notify() arrives and the monitor is reacquired, the JVM submits the VT back to the scheduler.

If you previously migrated some code from synchronized to ReentrantLock to escape pinning, the JEP explicitly says don't revert it. There's no need - the migrated code works fine. But you also don't need to migrate any more code. Use whichever lock fits the problem.

What’s still pinned

Pinning isn’t gone entirely. The remaining cases live around the JVM’s native boundary:

  • Native code calling back into Java that blocks. If you’re inside a native method or the Foreign Function & Memory API, and the native code calls back into Java, and that Java code blocks on a monitor or does I/O - the virtual thread is still pinned. There's a native frame on the stack, and the VT can't unmount across it.
  • Class loading. If a virtual thread triggers loading a class and the class loader blocks (waiting on I/O, say), the VT pins its carrier. Same reason: native frames in the call stack.
  • Class initialization. If a VT is the first to initialize a class and the static initializer blocks, or if a VT is waiting for another thread to finish initializing a class, the carrier gets pinned.

These are edge cases for most applications. The JEP’s position is “we’ll revisit if they prove problematic.” For now, the jdk.VirtualThreadPinned JFR event still fires for these, and it's been enhanced to tell you why the pinning happened and which carrier is involved.

What got removed

Two things vanish silently:

  • **jdk.tracePinnedThreads system property is gone.** The flag that printed a stack trace whenever a virtual thread pinned its carrier inside a synchronized block - removed. Setting it on the command line has no effect. JFR's jdk.VirtualThreadPinned event is the replacement.
  • The advice to migrate from synchronized to ReentrantLock purely for pinning reasons is no longer in the docs. Use whichever fits. Java Concurrency in Practice §13.4 - prefer synchronized for simple cases, reach for j.u.c.locks when you need fairness, read-write locks, or interruptible acquisition - is the official guidance now.

Compatibility

Source code: zero change required. Bytecode: zero change required. Library APIs: zero change required.

The one realistic risk the JEP calls out: monitor exit may be slightly slower. Releasing a monitor used to unpark a platform thread; now it sometimes has to queue a virtual thread back to the scheduler, which is currently a heavier operation. If you have extremely contended monitors on a hot path, you might see a small regression. The JEP doesn’t quantify this and most code won’t notice.

There’s also a JVM TI change: GetObjectMonitorUsage no longer returns information about monitors owned by virtual threads. Tools that rely on this (debuggers, profilers, custom monitoring) needed an update for JDK 24. If you're using an older agent against JDK 24, that's where things may break first.

Quick reference

Format below is before JDK 24 → JDK 24+:

  • Virtual thread inside synchronized is pinned to its carrier → virtual thread unmounts cleanly, carrier returns to scheduler
  • Blocking I/O inside synchronized blocks the platform thread too → blocks only the virtual thread
  • Object.wait() inside synchronized double-pins the carrier → unmounts the virtual thread
  • Library authors had to migrate synchronized to ReentrantLock for VT scaling → no migration needed; pick the lock that fits the problem
  • jdk.tracePinnedThreads flag printed pinning stacks → flag removed; use jdk.VirtualThreadPinned JFR event
  • JVM tracked monitor ownership by platform thread → JVM tracks ownership by virtual thread

Source: https://openjdk.org/jeps/491

What this signals

JEP 444 in JDK 21 was the headline announcement: virtual threads, finally. But anyone who actually deployed virtual threads in production found out within a week that they came with an asterisk. synchronized was the asterisk. Everyone in the room knew it. The official advice was "we're working on it."

JDK 24 is when the asterisk goes away. If you’ve been holding off on virtual threads because the audit-your-whole-stack work felt too painful, the audit is now optional. Drop your service onto JDK 24+, run the same code, and the pinning problem mostly stops mattering.

The remaining pinned cases — native callbacks, class loading — are real but rare. Most services I’ve seen don’t hit them often enough to care. If yours does, JFR will tell you exactly which call site is the culprit.

The bigger lesson is on Java’s release cadence. A feature this fundamental — changing how the JVM tracks monitor ownership across the entire language runtime — shipped three releases after the initial virtual-threads announcement. That’s eighteen months. For something that touches every synchronized keyword in every JAR in your dependency graph, eighteen months is fast. The model now matches the marketing.

This article is part of All Things Software. Follow for deep dives into Java, Spring Boot, Apereo CAS, and the tools that make working with them less painful.


메타데이터
post_id
dcf28cda9b19
slug
jep-translated-virtual-threads-no-longer-pin-dcf28cda9b19
url
https://medium.com/all-things-software/jep-translated-virtual-threads-no-longer-pin-dcf28cda9b19
canonical_url
https://medium.com/all-things-software/jep-translated-virtual-threads-no-longer-pin-dcf28cda9b19
author_url
https://medium.com/@dima767
status
ok
fetched_at
2026-06-24 23:31:39