Skip to content

Virtual Thread Pinning: Why synchronized Froze Your App

Virtual threads pinned by synchronized collapse throughput to your CPU count and can hang outright. Diagnose pinning on JDK 21 and fix it on 24+.

Java java-errors virtual-threads project-loom jvm concurrency jdk-21 jdk-25
Gopi Gorantala
Reading Progress

On This Page

You switched a service to Executors.newVirtualThreadPerTaskExecutor(), or set spring.threads.virtual.enabled=true, and expected thousands of concurrent requests. Instead throughput sat at roughly the number of CPU cores, or the application stopped responding altogether and no exception was ever thrown.

The cause is pinning. On JDK 21, 22 and 23, a virtual thread that blocks inside a synchronized block cannot release its carrier — the platform thread underneath it. The carrier blocks too. Your virtual-thread service quietly became a fixed-size thread pool with availableProcessors() slots.

1. The Error

There is no exception. That is what makes this expensive to diagnose. There are three signatures instead.

Signature one — the pinning trace. On JDK 21 with -Djdk.tracePinnedThreads=full:

VirtualThread[#23]/runnable@ForkJoinPool-1-worker-4 reason:MONITOR
    java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:199)
    java.base/jdk.internal.vm.Continuation.onPinned0(Continuation.java:393)
    java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:635)
    java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:807)
    java.base/java.lang.Thread.sleep(Thread.java:507)
    Repro.settle(Repro.java:23) <== monitors:1
    Repro.lambda$main$0(Repro.java:38)
    java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
    java.base/java.lang.VirtualThread.run(VirtualThread.java:329)

reason:MONITOR and the <== monitors:1 marker are the whole diagnosis: that frame holds a monitor, and the thread blocked while holding it. The trace prints once per distinct stack, not once per pin.

Signature two — a frozen process with a clean thread dump. jstack on a hung JDK 21 process shows carriers parked in Continuation.run with almost no CPU time, and reports no deadlock:

"ForkJoinPool-1-worker-1" #19 [3646] daemon prio=5 os_prio=0 cpu=0.93ms elapsed=12.63s
   Carrying virtual thread #18
	at jdk.internal.vm.Continuation.run(java.base@21.0.10/Continuation.java:248)
	at java.lang.VirtualThread.runContinuation(java.base@21.0.10/VirtualThread.java:245)
	at java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(java.base@21.0.10/ForkJoinPool.java:1312)
	at java.util.concurrent.ForkJoinPool.runWorker(java.base@21.0.10/ForkJoinPool.java:1808)
	at java.util.concurrent.ForkJoinWorkerThread.run(java.base@21.0.10/ForkJoinWorkerThread.java:188)

cpu=0.93ms against elapsed=12.63s is the fingerprint. Nothing is spinning; everything is stuck.

Signature three — a JFR event. jdk.VirtualThreadPinned fires when a pinned virtual thread blocks for longer than 20 ms. It is enabled by default under settings=default.

Environment for everything measured here: OpenJDK 21.0.10+7-Ubuntu-124.04 and 25.0.4+7-1-24.04-Ubuntu, Ubuntu 24.04, two available processors, no container limit, G1.

2. Version Behaviour Matrix

JDKBehaviour
8No virtual threads. Executors.newVirtualThreadPerTaskExecutor() fails to compile: cannot find symbol.
11Unchanged — same compile error (verified on 11.0.32).
17Unchanged — same compile error (verified on 17.0.20).
21Virtual threads GA (JEP 444). synchronized pins. Measured 10,060 ms for work that should take 200 ms. jdk.tracePinnedThreads works. jdk.VirtualThreadPinned has 4 fields.
25JEP 491 in effect. synchronized does not pin. Same code: 217 ms. jdk.tracePinnedThreads is gone and silently does nothing. The JFR event gains blockingOperation, pinnedReason, carrierThread. jcmd Thread.vthread_scheduler added.

JDK 19 and 20 shipped virtual threads as a preview. JDK 22 and 23 behave as 21. The change landed in JDK 24 (GA 18 March 2025) via JEP 491, tracked as JDK-8338813; JDK 25 is the first LTS to carry it, and JDK 26 went GA on 17 March 2026 with no reversal. Native-frame pinning survives on every release.

3. Reproduce It Yourself

a. What you need

  • OpenJDK 21.0.10+7 and OpenJDK 25.0.4+7 side by side. Container equivalents: eclipse-temurin:21.0.10_7-jdk and eclipse-temurin:25.0.4_7-jdk.
  • No build tool and no dependencies. Both files run with the single-file source launcher (JEP 330), so java Repro.java is enough.
  • Any machine. The numbers below are from a 2-CPU box; scale the expected timings by your own availableProcessors().

b. The setup files

Repro.java — each task locks its own object, so there is no lock contention at all. Any slowdown you measure is pinning.

import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Repro {

    static final class Account {
        final Object lock = new Object();
        long balanceMinor;
    }

    static final int TASKS = 100;
    static final int BLOCK_MILLIS = 200;

    static void settle(Account a) throws InterruptedException {
        synchronized (a.lock) {          // <== pinning site on JDK 21, 22 and 23
            Thread.sleep(BLOCK_MILLIS);  // stands in for a blocking JDBC / HTTP call
            a.balanceMinor += 1;
        }
    }

    public static void main(String[] args) throws Exception {
        System.out.println("java.version        = " + System.getProperty("java.version"));
        System.out.println("availableProcessors = " + Runtime.getRuntime().availableProcessors());

        List<Account> accounts = new ArrayList<>();
        for (int i = 0; i < TASKS; i++) accounts.add(new Account());

        Instant start = Instant.now();
        try (ExecutorService es = Executors.newVirtualThreadPerTaskExecutor()) {
            for (Account a : accounts) {
                es.submit(() -> { settle(a); return null; });
            }
        }
        long ms = Duration.between(start, Instant.now()).toMillis();

        long done = accounts.stream().filter(a -> a.balanceMinor == 1).count();
        System.out.println("settled             = " + done + "/" + TASKS);
        System.out.println("wall clock          = " + ms + " ms");
        System.out.println("ideal (unpinned)    = ~" + BLOCK_MILLIS + " ms");
    }
}

WaitPin.java — the same mechanism, escalated from slow to stuck. Waiters block in Object.wait() inside synchronized; notifiers can never be scheduled because the carriers are pinned.

import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class WaitPin {

    static final class Box {
        final Object lock = new Object();
        boolean ready;
    }

    static final int TASKS = 50;

    public static void main(String[] args) throws Exception {
        List<Box> boxes = new ArrayList<>();
        for (int i = 0; i < TASKS; i++) boxes.add(new Box());

        Instant start = Instant.now();
        try (ExecutorService es = Executors.newVirtualThreadPerTaskExecutor()) {
            for (Box b : boxes) {
                es.submit(() -> {                      // waiter
                    synchronized (b.lock) {
                        while (!b.ready) b.lock.wait(); // <== pins, then never releases
                    }
                    return null;
                });
            }
            Thread.sleep(400);
            for (Box b : boxes) {
                es.submit(() -> {                      // notifier, never gets a carrier
                    synchronized (b.lock) { b.ready = true; b.lock.notifyAll(); }
                    return null;
                });
            }
        }
        System.out.println("java.version = " + System.getProperty("java.version")
            + "  completed in " + Duration.between(start, Instant.now()).toMillis() + " ms");
    }
}

c. The exact command line

# -Djdk.virtualThreadScheduler.parallelism pins the carrier count so the result is
# reproducible on any machine; maxPoolSize caps growth. Both are JDK 21+ properties.
java -Djdk.tracePinnedThreads=full \
     -Djdk.virtualThreadScheduler.parallelism=4 \
     -Djdk.virtualThreadScheduler.maxPoolSize=4 \
     -XX:StartFlightRecording=settings=default,filename=/tmp/pin.jfr \
     Repro.java

d. Numbered steps

Step 1 - slow path, JDK 21, default scheduler
$ /path/to/jdk-21/bin/java Repro.java
java.version        = 21.0.10
availableProcessors = 2
settled             = 100/100
wall clock          = 10060 ms
ideal (unpinned)    = ~200 ms

Step 2 - same code, JDK 25
$ /path/to/jdk-25/bin/java Repro.java
wall clock          = 217 ms

Step 3 - pin the carrier count so the arithmetic is visible
$ /path/to/jdk-21/bin/java -Djdk.virtualThreadScheduler.parallelism=4 \
      -Djdk.virtualThreadScheduler.maxPoolSize=4 Repro.java
wall clock          = 5023 ms          # 100 tasks / 4 carriers * 200 ms

Step 4 - prove the scheduler does NOT add carriers to compensate
$ /path/to/jdk-21/bin/java -Djdk.virtualThreadScheduler.parallelism=4 Repro.java
wall clock          = 5027 ms          # maxPoolSize defaults to 256 and is never used

Step 5 - hard hang, JDK 21
$ timeout 25 /path/to/jdk-21/bin/java -Djdk.virtualThreadScheduler.parallelism=2 \
      -Djdk.virtualThreadScheduler.maxPoolSize=2 WaitPin.java ; echo "exit=$?"
exit=124                               # killed by timeout, never completed

Step 6 - same code, JDK 25
$ /path/to/jdk-25/bin/java -Djdk.virtualThreadScheduler.parallelism=2 \
      -Djdk.virtualThreadScheduler.maxPoolSize=2 WaitPin.java
java.version = 25.0.4  completed in 414 ms

Step 7 - inspect the frozen JDK 21 process
$ jstack <pid> | grep -c "Found one Java-level deadlock"
0
$ jcmd <pid> Thread.dump_to_file -format=json /tmp/vt.json

The JSON dump of the hung run holds 109 thread entries. Nine carry a stack; 100 are empty — the backlog of virtual threads that never got a carrier. The dump proves work is queued and tells you nothing about why.

e. The confirmation signal

On JDK 21, Repro prints settled = 100/100 and a wall clock of 10,060 ms on a 2-CPU box. The same binary on JDK 25 prints 217 ms. A ratio near TASKS / availableProcessors() is pinning; anything else is ordinary lock contention and this article is not your problem.

For the hang: JDK 21 WaitPin never terminates and timeout reports exit code 124, while JDK 25 finishes in about 400 ms. If instead you see a stack trace, you have a real deadlock, not pinning — jstack would have printed Found one Java-level deadlock.

f. Environment-specific triggers

  • Only on JDK 23 and earlier. On 24+ the synchronized cases are gone. Native-frame pinning remains on every release.
  • Only when the blocking call is inside the monitor. Move Thread.sleep above the synchronized block and JDK 21 finishes in ~200 ms.
  • Severity scales with availableProcessors(). A 2-core container amplifies this by 50x where a 64-core host hides it. Container CPU limits therefore make the symptom worse in production than on a laptop.
  • The hang needs the pinned carriers to be the only carriers. With 50 waiters and 50 carriers, WaitPin completes on JDK 21 too. Small pods hang; large hosts merely stall.
  • Thread.sleep is a stand-in. Real triggers are blocking I/O, Object.wait, CountDownLatch.await, and Future.get inside synchronized.

g. Teardown

rm -f /tmp/pin.jfr /tmp/vt.json Repro.class WaitPin.class

4. Why It Happens — Surface Level

A virtual thread runs on a carrier: an ordinary platform thread from a dedicated ForkJoinPool. When the virtual thread blocks, the JVM normally copies its stack onto the heap, detaches it from the carrier, and hands that carrier to another virtual thread. That copy-and-detach step is called unmounting, and it is the entire reason virtual threads scale.

Before JDK 24, HotSpot could not unmount a stack that held a monitor. Monitor ownership was recorded against the carrier, so moving the stack elsewhere would have left the lock attached to the wrong thread. Faced with that, the runtime did the only safe thing: it kept the virtual thread mounted and blocked the carrier instead.

5. Why It Happens — Under the Hood

A continuation is the heap-allocated copy of a virtual thread's stack that unmounting produces. Freezing writes the stack to the heap; thawing copies it back onto a carrier.

Freezing walks the stack frame by frame. If it meets a frame the runtime cannot relocate, it aborts and reports why. On JDK 21 the reasons live in jdk.internal.vm.Continuation$Pinned, an enum with exactly three constants — NATIVE, MONITOR, CRITICAL_SECTION — which you can read straight out of the shipped class file:

$ javap -p --module java.base 'jdk.internal.vm.Continuation$Pinned'
  public static final ... NATIVE;
  public static final ... MONITOR;
  public static final ... CRITICAL_SECTION;

MONITOR was the common one. When the freeze aborted with it, VirtualThread.parkOnCarrierThread ran instead: the virtual thread parked the carrier itself. From the operating system's point of view the carrier was simply a blocked platform thread.

Here is the part that surprises people. The scheduler does not compensate. ForkJoinPool grows its worker count when a task signals a managed block, and a pinned park is not one. Step 4 above proves it: with parallelism=4 and the default maxPoolSize of 256, JDK 21 still took 5,027 ms and never created a fifth carrier. Your effective concurrency is parallelism, full stop — and parallelism defaults to availableProcessors().

That is the loop that closes on you. Virtual threads are cheap, so you create one per request. Each one enters a synchronized block and blocks. Carrier count is fixed. Requests queue behind two carriers. Nothing throws, nothing logs, and the thread dump shows two idle-looking workers.

JEP 491 fixed this by moving monitor ownership off the carrier. A virtual thread can now acquire, hold and release a monitor independently of whatever platform thread it happens to be running on, so synchronized and Object.wait unmount like any other blocking operation. You can see the rewrite in the class files without reading a line of source. JDK 21's VirtualThread carries these string constants:

jdk.tracePinnedThreads     full     short     jdk.unparker.maxPoolSize

JDK 25's carries none of them. It has LockSupport.park, a renamed VirtualThread-unblocker helper thread, and a new native method:

private static native void postPinnedEvent(java.lang.String);

The reason strings moved into HotSpot. libjvm.so on JDK 25 contains the literal Native or VM frame on stack and the symbol JVM_VirtualThreadPinnedEvent; on JDK 21, strings | grep -c returns 0 for both.

What still pins on JDK 24+: a native or VM frame on the stack. In practice that means JNI, and Foreign Function & Memory upcalls where native code calls back into Java and that Java code blocks. Section 8 shows the event this produces.

6. The Fix

Upgrade to JDK 24 or later. JEP 491 removes the mechanism, so every synchronized pinning site in your code and in every library you depend on is fixed at once. Nothing to find, nothing to rewrite. JDK 25 is the LTS.

If you are on 21 and cannot move yet, the fix works because ReentrantLock is built on AbstractQueuedSynchronizer, which parks through LockSupport — an operation the runtime can unmount.

 static final class Account {
-    final Object lock = new Object();
+    final ReentrantLock lock = new ReentrantLock();
     long balanceMinor;
 }

 static void settle(Account a) throws InterruptedException {
-    synchronized (a.lock) {
+    a.lock.lock();
+    try {
         Thread.sleep(BLOCK_MILLIS);
         a.balanceMinor += 1;
+    } finally {
+        a.lock.unlock();
     }
 }

Measured on JDK 21 with parallelism=4: 214 ms, against 5,023 ms for the synchronized version.

SituationDo thisCost
You control the release trainUpgrade to 25 (LTS)Full JDK upgrade validation
Stuck on 21, the monitor is in your codeSwap to ReentrantLockManual finally; loses lock coarsening and biased-path JIT work
Stuck on 21, the monitor is in a libraryKeep that call on a platform threadTwo execution models to reason about
You need throughput todayRaise jdk.virtualThreadScheduler.parallelismSee below

Raising parallelism does work, and it tells you exactly what you have built. On JDK 21, Repro measured 10,036 ms at parallelism 2, 2,628 ms at 8, 819 ms at 32, and 257 ms at 100. Carriers are platform threads with real stacks, so at that point you have re-created a fixed-size thread pool and paid for virtual threads as well.

These are not fixes:

  • Removing synchronized without understanding the invariant it protected. You will trade a throughput problem for a data race.
  • Moving the blocking call outside the monitor when the monitor exists to make that call atomic. Read the invariant first.
  • Setting -Djdk.tracePinnedThreads on JDK 24+ to "check whether it still happens". The property is gone. It prints nothing, silently, whether or not you are pinned.

7. Best Practices & The Better Design

The durable rule: never hold a lock across a blocking call. It cost you throughput on platform threads, it cost you liveness on virtual threads before JDK 24, and it remains a design smell after. Do the I/O outside the critical section and take the lock only to publish the result.

static void settle(Account a) throws InterruptedException {
    long amountMinor = fetchFromLedger();   // blocking work, no lock held
    synchronized (a.lock) {                 // lock held for nanoseconds
        a.balanceMinor += amountMinor;
    }
}

Beyond that:

  • Prefer a lock-free update — AtomicLong, ConcurrentHashMap.compute, LongAdder — over a monitor around a field.
  • Pin the scheduler explicitly. jdk.virtualThreadScheduler.parallelism defaults to availableProcessors(), which on a CPU-limited pod may be 1 or 2. Set it deliberately rather than inheriting it.
  • Do not pool virtual threads. One virtual thread per task; bound concurrency with a Semaphore instead. (See the executor-lifecycle failures in java-rejectedexecutionexception-threadpoolexecutor.)
  • Do not cache per-request state in a ThreadLocal when the thread is virtual and unbounded. Use ScopedValue where available.
  • Keep JNI and FFM calls on platform threads. They are the one pinning source JEP 491 did not remove.

8. How to Prevent It Long-Term

On JDK 21–23, find the sites before you upgrade. jdk.VirtualThreadPinned is on by default at a 20 ms threshold under settings=default:

java -XX:StartFlightRecording=settings=default,filename=/tmp/pin.jfr -jar app.jar
jfr summary /tmp/pin.jfr | grep -i pinned
jfr print --events jdk.VirtualThreadPinned /tmp/pin.jfr

The 100-task run above produced exactly 100 events at ~200 ms each. Every event's stack trace names a monitor site worth rewriting. -Djdk.tracePinnedThreads=full is the interactive equivalent, and it is the option to use in a load test rather than in production — it prints from inside the blocking path.

On JDK 24+, the event tells you far more. It gained blockingOperation, pinnedReason and carrierThread. A real capture from an FFM upcall that sleeps:

jdk.VirtualThreadPinned {
  duration = 150 ms
  blockingOperation = "LockSupport.park"
  pinnedReason = "Native or VM frame on stack"
  carrierThread = "ForkJoinPool-1-worker-1" (javaThreadId = 28)
  eventThread = "sorter" (javaThreadId = 27, virtual)
}

Any occurrence of this event on JDK 24+ is genuine native pinning. Alert on it at count > 0 over 5 minutes — unlike the JDK 21 event, it is not expected background noise.

Watch the scheduler, not the thread count. JDK 25 adds two diagnostic commands that JDK 21 does not have:

$ jcmd <pid> Thread.vthread_scheduler
java.util.concurrent.ForkJoinPool@1f4bd94[Running, parallelism = 4, size = 4,
  active = 0, running = 0, steals = 50, tasks = 0, submissions = 0, delayed = 50]

running stuck at 0 while submissions climbs is the saturation signal. jcmd <pid> Thread.vthread_pollers covers the I/O side.

Metrics and CI:

  • Alert when p99 latency divided by median exceeds 10 while CPU stays under 30%. Pinning is the classic "idle box, slow service" shape.
  • Compare jcmd <pid> Thread.dump_to_file -format=json entry count against your in-flight request count. A large gap of empty-stack entries is queued work that has no carrier.
  • Run your load test with parallelism=1. A correctly unpinned service gets slower; a pinned one stops.
  • Grep the dependency tree for synchronized in blocking paths before adopting virtual threads on 21. jdeps will not find this; a load test with the JFR event will.

Related failures worth reading next: platform-thread deadlocks and why virtual-thread cycles stay invisible to jstack (java-deadlock-found-one-java-level-deadlock), and thread-pool saturation on the platform-thread side (java-rejectedexecutionexception-threadpoolexecutor).

9. Key Takeaways

  • On JDK 21–23, blocking inside synchronized pins the carrier and caps concurrency at jdk.virtualThreadScheduler.parallelism, which defaults to availableProcessors(). Measured: 10,060 ms versus 217 ms on JDK 25 for identical code.
  • The scheduler never adds carriers to compensate. maxPoolSize defaults to 256 and stays unused while your service crawls.
  • Pinning escalates from slow to stuck. Object.wait under synchronized with two carriers hangs forever on JDK 21 and completes in 414 ms on JDK 25 — and jstack reports no deadlock either way.
  • -Djdk.tracePinnedThreads was removed in JDK 24. On 24+ it prints nothing and warns about nothing. Use the jdk.VirtualThreadPinned JFR event, which now carries pinnedReason and carrierThread.
  • JEP 491 did not remove all pinning. A native or VM frame on the stack — JNI, FFM upcalls — still pins on every release.
Javajava-errorsvirtual-threadsproject-loomjvmconcurrencyjdk-21jdk-25

I'm Gopi — 15+ years in Java, building Kafka and Flink platforms for banks, where one lost event is a financial discrepancy. I write javahandbook.com because the guides I needed didn't exist. Everything here is tested against a real cluster first.

Comments