Skip to main content

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

Updated 9 min readMarkdown
Part of seriesJava Concurrency & Virtual Threads
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 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. 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)
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.

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

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

=== 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>
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();
        }
    }
}
javac -d out PinnedEvents.java
java -cp out PinnedEvents

Real output:

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

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:

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 · Coming next: replacing ThreadLocal with ScopedValue in Java 25.

No comments yet. Be the first to comment.