# Virtual Thread Pinning in Java 25: What Still Pins After JEP 491

In Java 21, virtual threads had one well-known catch: block inside a `synchronized` block and the virtual thread gets **pinned** to its carrier thread. That carrier, which is a real OS thread, can't run anything else until the virtual thread is done.

[JEP 491](https://openjdk.org/jeps/491) changed that in JDK 24. So in Java 25 (the current LTS), pinning is gone, right?

**Not entirely.** `synchronized` no longer pins, but a virtual thread that blocks inside a **class initializer** (`static` field setup, `static {}` blocks) still does. Below, the same programs run on JDK 21 and JDK 25 so you can see both behaviours, then we use JFR to catch the remaining case and fix it.

> New to virtual threads? Start with my overview post, [Mastering Concurrency in Java: From Traditional Multithreading to Virtual Threads](https://gowthammuthuvel.hashnode.dev/mastering-concurrency-in-java-from-traditional-multithreading-to-virtual-threads). This post assumes you know what a carrier thread is.

## TL;DR

| Blocking while the virtual thread is… | JDK 21 | JDK 24 / 25 |
| --- | --- | --- |
| inside `synchronized` (or `Object.wait()`) | pinned | **not pinned** |
| inside a class initializer (`<clinit>`) | pinned | **still pinned** |
| waiting for another thread to finish initializing a class | pinned | **still pinned** (improved in JDK 26 for common paths, per [JDK-8369238](https://bugs.openjdk.org/browse/JDK-8369238)) |
| in a native method or FFM call that calls back into Java and blocks | pinned | **still pinned** |

The list of remaining cases comes from the "Future Work" section of JEP 491. The first two rows are measured below.

## Pinning in one picture

![Pinned vs unpinned virtual thread: on JDK 21 synchronized or any JDK in a class initializer the carrier stays stuck and VT-2 waits; on JDK 25 synchronized the virtual thread unmounts and VT-2 runs.](https://cdn.hashnode.com/uploads/covers/658c3a46e339366b1a80b9f3/6f54b0a6-17c6-4c31-9920-2b7f637b290c.png align="center")

When a virtual thread blocks, it normally *unmounts* and the carrier picks up another virtual thread. Pinned, it can't unmount, so the carrier sits idle.

## Experiment 1: does `synchronized` still pin?

The test: 200 virtual threads, each takes **its own** lock (so there's no contention) and sleeps 20 ms while holding it. We force the scheduler down to **one** carrier thread with `-Djdk.virtualThreadScheduler.parallelism=1`, which turns pinning into an obvious timing difference:

- pinned: the tasks run one after another, about 200 × 20 ms ≈ 4 s
- not pinned: all the sleeps overlap, so it takes a few tens of milliseconds

```java
import java.time.Duration;
import java.util.concurrent.Executors;

/**
 * 200 virtual threads, each takes its OWN lock and sleeps 20 ms inside it.
 * The virtual-thread scheduler is limited to ONE carrier thread
 * (-Djdk.virtualThreadScheduler.parallelism=1), so pinning is easy to see:
 *  - pinned:   tasks run one after another  -> ~200 x 20 ms = ~4 s
 *  - unpinned: sleeps overlap               -> a few tens of ms
 */
public class PinningBenchmark {

    static final int TASKS = 200;
    static final Duration SLEEP = Duration.ofMillis(20);

    public static void main(String[] args) throws Exception {
        System.out.println("Java " + Runtime.version()
                + ", carrier parallelism = "
                + System.getProperty("jdk.virtualThreadScheduler.parallelism", "default"));

        long start = System.nanoTime();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < TASKS; i++) {
                executor.submit(() -> {
                    Object lock = new Object();      // no contention: every task has its own lock
                    synchronized (lock) {
                        Thread.sleep(SLEEP);         // blocking call while holding a monitor
                    }
                    return null;
                });
            }
        } // close() waits for all tasks
        long millis = Duration.ofNanos(System.nanoTime() - start).toMillis();
        System.out.println(TASKS + " tasks x " + SLEEP.toMillis() + " ms sleep inside synchronized: "
                + millis + " ms");
    }
}
```

Run it on both JDKs:

```bash
javac -d out PinningBenchmark.java
java -Djdk.virtualThreadScheduler.parallelism=1 -cp out PinningBenchmark
```

Real output on my machine (Debian builds of OpenJDK 21.0.12 and 25.0.4):

```text
=== JDK 21 benchmark ===
Java 21.0.12.1+1-1-deb13u1-Debian, carrier parallelism = 1
200 tasks x 20 ms sleep inside synchronized: 4044 ms
=== JDK 25 benchmark ===
Java 25.0.4.1+1-1-deb13u1-Debian, carrier parallelism = 1
200 tasks x 20 ms sleep inside synchronized: 34 ms
```

On JDK 21 the run takes 4,044 ms, which is exactly the serial time, because every sleep held the only carrier hostage. On JDK 25 it takes 34 ms. Same code, no `ReentrantLock` rewrite needed.

## Experiment 2: catch pinning with JFR, including the case that still exists

Timing tells you *something* is pinned. **JDK Flight Recorder** tells you *where*. The JVM records a `jdk.VirtualThreadPinned` event whenever a virtual thread blocks while pinned. Since JDK 24 the event also includes a `pinnedReason` field. (The old `-Djdk.tracePinnedThreads` flag was removed in JDK 24, so JFR is now the tool to use.)

The program below records that event for three scenarios:

1. sleeping inside `synchronized`
2. sleeping inside a **static initializer**, the classic "load config in a static field" pattern
3. the fix for #2: the same slow load done lazily under a lock, outside `<clinit>`

```java
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import jdk.jfr.Recording;
import jdk.jfr.consumer.RecordedEvent;
import jdk.jfr.consumer.RecordingFile;

/**
 * Records jdk.VirtualThreadPinned JFR events for two scenarios and prints them:
 *   1. sleeping inside a synchronized block
 *   2. sleeping inside a static initializer (<clinit>)
 *   3. the fix for 2: lazy, lock-guarded initialization outside <clinit>
 */
public class PinnedEvents {

    static final Object LOCK = new Object();

    /** A class whose static initializer blocks (e.g. loads config over the network). */
    static class SlowConfig {
        static final String VALUE = load();

        static String load() {
            sleep(100);
            return "loaded";
        }
    }

    /** Same slow load, but done lazily under a normal lock instead of in <clinit>. */
    static final class LazyConfig {
        private static volatile String value;   // static field with no initializer: <clinit> does no work

        static String get() {
            String v = value;
            if (v == null) {
                synchronized (LazyConfig.class) {   // JDK 24+: blocking here does not pin
                    v = value;
                    if (v == null) {
                        sleep(100);
                        value = v = "loaded";
                    }
                }
            }
            return v;
        }
    }

    public static void main(String[] args) throws Exception {
        System.out.println("Java " + Runtime.version());
        Path jfr = Files.createTempFile("pinning", ".jfr");

        try (Recording recording = new Recording()) {
            recording.enable("jdk.VirtualThreadPinned").withThreshold(Duration.ofMillis(10)).withStackTrace();
            recording.start();

            runInVirtualThread("synchronized", () -> {
                synchronized (LOCK) {
                    sleep(100);
                }
            });
            runInVirtualThread("class-init", () -> System.out.println("  SlowConfig.VALUE = " + SlowConfig.VALUE));
            runInVirtualThread("lazy-init", () -> System.out.println("  LazyConfig.get() = " + LazyConfig.get()));

            recording.stop();
            recording.dump(jfr);
        }

        var events = RecordingFile.readAllEvents(jfr);
        System.out.println("jdk.VirtualThreadPinned events: " + events.size());
        for (RecordedEvent e : events) {
            String thread = e.getThread() == null ? "?" : e.getThread().getJavaName();
            String reason = e.hasField("pinnedReason") ? e.getString("pinnedReason") : "(field not available on this JDK)";
            System.out.printf("  thread=%-13s duration=%3d ms  reason=%s%n",
                    thread, e.getDuration().toMillis(), reason);
        }
        Files.delete(jfr);
    }

    static void runInVirtualThread(String name, Runnable task) throws InterruptedException {
        System.out.println("scenario: " + name);
        Thread.ofVirtual().name(name).start(task).join();
    }

    static void sleep(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}
```

```bash
javac -d out PinnedEvents.java
java -cp out PinnedEvents
```

Real output:

```text
=== JDK 21 events ===
Java 21.0.12.1+1-1-deb13u1-Debian
scenario: synchronized
scenario: class-init
  SlowConfig.VALUE = loaded
scenario: lazy-init
  LazyConfig.get() = loaded
jdk.VirtualThreadPinned events: 3
  thread=lazy-init     duration=100 ms  reason=(field not available on this JDK)
  thread=synchronized  duration= 98 ms  reason=(field not available on this JDK)
  thread=class-init    duration=100 ms  reason=(field not available on this JDK)
=== JDK 25 events ===
Java 25.0.4.1+1-1-deb13u1-Debian
scenario: synchronized
scenario: class-init
  SlowConfig.VALUE = loaded
scenario: lazy-init
  LazyConfig.get() = loaded
jdk.VirtualThreadPinned events: 1
  thread=class-init    duration=100 ms  reason=VM call to PinnedEvents$SlowConfig.<clinit> on stack
```

How to read this:

- **JDK 21** pins in all three scenarios, including the "fix", because the fix uses `synchronized`.
- **JDK 25** pins only for `class-init`, and JFR names the exact cause: `VM call to PinnedEvents$SlowConfig.<clinit> on stack`. The JVM runs a class initializer through a native frame, and a virtual thread can't unmount with a native frame on its stack.
- The `lazy-init` fix does the same 100 ms of blocking but produces **no pinning event on JDK 25**.

## Finding this in a real app (no code changes)

You don't need to write a recorder. Start your app with JFR enabled, then print the events:

```bash
java -XX:StartFlightRecording=filename=app.jfr,settings=profile -jar your-app.jar
# ...exercise the app, stop it, then:
jfr print --events jdk.VirtualThreadPinned --stack-depth 4 app.jfr
```

This is what JDK 25 printed for the demo above:

```text
jdk.VirtualThreadPinned {
  startTime = 14:52:27.709 (2026-10-27)
  duration = 100 ms
  blockingOperation = "LockSupport.park"
  pinnedReason = "VM call to PinnedEvents$SlowConfig.<clinit> on stack"
  carrierThread = "ForkJoinPool-1-worker-1" (javaThreadId = 31)
  eventThread = "class-init" (javaThreadId = 33, virtual)
  stackTrace = [
    java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 833
    java.lang.VirtualThread.parkNanos(long) line: 801
    java.lang.VirtualThread.sleepNanos(long) line: 983
    java.lang.Thread.sleepNanos(long) line: 507
  ]
}
```

`pinnedReason` tells you *why* and `carrierThread` tells you *which* OS thread was held. Raise `--stack-depth` to see the frames below the sleep, which lead to your own code. For a running process, `jcmd <pid> JFR.start` works too.

## What to change in your code

1. **Upgrade to JDK 24+ before rewriting locks.** On 24+ there's no need to replace `synchronized` with `ReentrantLock` just to avoid pinning. JEP 491 itself says you can now choose between them "based solely upon which best solves the problem at hand."
2. **Don't block inside static initializers.** Watch for `static final Config CONFIG = loadFromNetwork();`, `static {}` blocks that read files or call services, and singletons whose constructor does I/O and is called from a static field. Move that work to:
   - explicit startup code (for example, a Spring bean that's initialized at boot), or
   - lazy initialization under a normal lock, like `LazyConfig` above.

   Note that the popular *holder-class idiom* (`static class Holder { static final X INSTANCE = ... }`) **is** class initialization, so it pins too if the initializer blocks.
3. **Keep a JFR check for native and FFM code.** If your app calls JNI/FFM code that calls back into Java and blocks, that path can still pin.
4. **Know the scale of the problem.** One pinned thread for 100 ms at startup is harmless. It only hurts when many virtual threads block while pinned at the same time and use up the carriers (by default the carrier count equals the CPU core count). That's why Experiment 1 used a single carrier: to make the effect visible.

## Summary

- JDK 24+ (including Java 25 LTS): `synchronized` and `Object.wait()` **no longer pin** virtual threads. Measured: 4,044 ms on JDK 21 vs 34 ms on JDK 25 for the same code.
- **Still pinned:** blocking in class initializers, waiting on another thread's class initialization, and native/FFM callbacks that block.
- **Detect it** with the `jdk.VirtualThreadPinned` JFR event. Its `pinnedReason` field points straight at the `<clinit>`.
- **Fix it** by moving blocking work out of static initializers, not by replacing `synchronized`.

Both programs above are complete: copy them, then `javac` and `java`. Tested on OpenJDK 21.0.12 and 25.0.4 (Linux x64).

*Related:* [Java 23: Shaping the Future of Development with Revolutionary Features](https://gowthammuthuvel.hashnode.dev/java-23-shaping-the-future-of-development-with-revolutionary-features) · *Coming next:* replacing `ThreadLocal` with `ScopedValue` in Java 25.
