> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Virtual Thread Pinning: Why synchronized Froze Your App
- URL: https://www.ggorantala.dev/java-virtual-thread-pinning-synchronized-jep-491/
- Published: 2026-09-12T21:55:47.000Z
- Updated: 2026-09-12T21:55:47.000Z
- Description: 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+.
- Author: Gopi Gorantala
- Tags: Java, java-errors, virtual-threads, project-loom, jvm, concurrency, jdk-21, jdk-25

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`:

```log
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:

```log
"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

| JDK | Behaviour                                                                                                                                                                                                                                |
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 8   | No virtual threads. Executors.newVirtualThreadPerTaskExecutor() fails to compile: cannot find symbol.                                                                                                                                    |
| 11  | Unchanged — same compile error (verified on 11.0.32).                                                                                                                                                                                    |
| 17  | Unchanged — same compile error (verified on 17.0.20).                                                                                                                                                                                    |
| 21  | Virtual 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.                                                            |
| 25  | JEP 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.

```java
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.

```java
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

```bash
# -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

```log
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

```bash
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:

```bash
$ 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:

```log
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:

```log
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.

```diff
 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.

| Situation                                | Do this                                      | Cost                                                           |
| ---------------------------------------- | -------------------------------------------- | -------------------------------------------------------------- |
| You control the release train            | Upgrade to 25 (LTS)                          | Full JDK upgrade validation                                    |
| Stuck on 21, the monitor is in your code | Swap to ReentrantLock                        | Manual finally; loses lock coarsening and biased-path JIT work |
| Stuck on 21, the monitor is in a library | Keep that call on a platform thread          | Two execution models to reason about                           |
| You need throughput today                | Raise jdk.virtualThreadScheduler.parallelism | See 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.

```java
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`:

```bash
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:

```java
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:

```bash
$ 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.