Virtual threads in production: what actually breaks

Virtual threads landed in Java 21. They’re stable, they deliver on their promise, and there isn’t much left to say about them.

The problem is elsewhere. Almost everything written about them dates from their early days. Those articles warn against a flaw that has since been fixed. So people refactor code for nothing. Meanwhile, the real traps go unnoticed.

This article takes stock on Java 25. What to forget, what still holds, and what genuinely breaks in production. All of it was checked on a 25.0.1, with real output.

First, the carrier

A virtual thread doesn’t run on its own. To execute, it needs a real operating system thread. That real thread is called the carrier.

The JVM keeps a small stock of them, in a ForkJoinPool reserved for the job. By default there are as many as you have processors. Ten processors, ten carriers. Not one more.

The back and forth is simple. When a virtual thread has work to do, the JVM mounts it on a free carrier. It runs. As soon as it blocks, on a network call or a SQL query, the JVM unmounts it: its stack is copied to the heap, and the carrier goes back to the stock. Another virtual thread takes its place straight away.

That’s the whole trick. A carrier never sits around waiting. Ten carriers are then enough to run hundreds of thousands of virtual threads, as long as those threads spend their time waiting.

One detail that matters: in a container, that processor count is whatever the JVM works out from the cgroup. Your virtual threads’ parallelism therefore rides directly on what Tuning the JVM in a container describes.

And pinning

Pinning is when the unmount doesn’t happen. The virtual thread blocks, but it keeps its carrier. The carrier isn’t working anymore, and it can’t carry anyone else. It’s tied up for nothing.

One lost carrier is no big deal. The trouble is there are only ten. And the scheduler does not compensate for pinning: it won’t make a replacement carrier. It knows how to do that for some blocking operations, up to a default ceiling of 256 threads. But not for this one.

Pinned carriers pile up against a wall, with no relief valve. When there isn’t a single free one left, the application doesn’t slow down: it stops. No virtual thread can run at all. That’s starvation. And if the pinned work is itself waiting on a virtual thread, it’s a deadlock.

That’s why pinning is frightening. Up to JDK 23, synchronized caused exactly that. Hence the advice you read everywhere: replace your synchronized blocks with ReentrantLock.

That advice was right for two years. It isn’t anymore.

What JEP 491 changed

JEP 491 landed in JDK 24, not 25. The distinction matters. People moving from LTS to LTS meet it when they upgrade to 25, and credit the 25 for it. The work was done one release earlier.

So why did synchronized pin? Down to a plumbing detail. The JVM recorded which platform thread owned a monitor. The carrier, in other words, not the virtual thread. Unmounting a virtual thread in the middle of a synchronized block would then have handed the monitor to whichever virtual thread mounted next. Mutual exclusion would have collapsed. Rather than risk that, the JVM kept everyone where they were.

JEP 491 reworked HotSpot’s monitors. They now belong to the virtual thread, not to its carrier. Unmounting becomes safe. Three cases are fixed by it: blocking inside a synchronized block, blocking to enter a synchronized block someone else holds, and Object.wait().

Let’s check rather than take it on faith. The program below starts a virtual thread that sleeps for two seconds inside a synchronized block. Then, 200 ms later, a second virtual thread that does nothing but print when it managed to run:

public class Pinning {
    static final Object lock = new Object();

    public static void main(String[] args) throws Exception {
        long t0 = System.currentTimeMillis();

        Thread holder = Thread.ofVirtual().name("holder").start(() -> {
            synchronized (lock) {
                try { Thread.sleep(2000); } catch (InterruptedException e) {}
            }
        });

        Thread.sleep(200);

        Thread other = Thread.ofVirtual().name("other").start(() ->
            System.out.println("other ran at t+" + (System.currentTimeMillis() - t0) + " ms"));

        other.join();
        holder.join();
    }
}

All of it with a single carrier, to leave no way out:

java -Djdk.virtualThreadScheduler.parallelism=1 \
     -Djdk.virtualThreadScheduler.maxPoolSize=1 Pinning.java

On JDK 25:

other ran at t+210 ms

other runs right away. The carrier was released during the sleep, while holder still held the monitor. On a JDK 21, the same code would have waited for the sleep to finish, a full two seconds: the only carrier was pinned, and other couldn’t even start.

The consequence is simple. Refactoring synchronized into ReentrantLock for pinning reasons has no purpose on a recent JDK. The JEP says so itself: no need to revert if you’ve already done it, but the migration isn’t necessary anymore. ReentrantLock keeps its own strengths, timed locking, fairness, interruptible locking. Pinning is no longer one of them.

While we’re at it, the jdk.tracePinnedThreads property didn’t survive JEP 491. It has no effect on JDK 25, and it won’t tell you so, including when there is real pinning. Two reasons for its removal. It had little left to trace. And it printed its stack traces from critical code, which earned it a long run of hang bugs. If your runbook mentions it, it’s as up to date as the rest of the web.

What still pins on Java 25

Pinning hasn’t gone away, it has shrunk to rarer cases, all tied to a native frame being on the stack or to blocking inside the VM itself:

The last three all revolve around class loading and initialization. The easiest to reproduce is blocking inside a class initializer. Ten lines will do it. A class whose static block blocks, and the virtual thread that triggers the initialization pins its carrier for the whole duration:

static class Slow {
    static {
        try { Thread.sleep(2000); } catch (InterruptedException e) {}
    }
    static void touch() {}
}

Same setup as before, one carrier, one virtual thread touching Slow and another that just wants to run:

other ran at t+2011 ms

This time other waited the full two seconds. The carrier really was pinned.

The case looks theoretical. It looks a lot less so once you meet a static block that reads a config file, opens a connection or calls a discovery service. It only happens once, on first load. But startup is precisely when every request shows up at the same time.

Only one of those four cases is being fixed, and the distinction matters. JDK 26 unmounts the virtual thread that is waiting for another thread to run a class initializer. On the most common interpreted paths only: invokestatic, new, getstatic, putstatic.

That’s the case the OpenJDK bug report is talking about when it goes all the way to deadlock. Every carrier is pinned waiting on an initialization. And the initializing thread is blocked on a virtual thread nobody can run anymore. Nothing moves.

Don’t mix the two up: the case demonstrated here, blocking inside a <clinit>, comes down to a native frame. That one still pins on 26.

As for the sibling case, network I/O during class loading in a virtual thread, it’s still open as of today. In other words, on Java 25, this is the pinning you’re most likely to meet without having written a single line of native code.

Seeing it, with JFR

Since jdk.tracePinnedThreads is dead, pinning is watched with Java Flight Recorder. The jdk.VirtualThreadPinned event is enabled by default, with a 20 ms threshold. Which means that in any JFR recording, the pinning that lasts long enough to hurt you is already in there. There’s nothing to switch on.

java -XX:StartFlightRecording=filename=pin.jfr Clinit.java
jfr print --events jdk.VirtualThreadPinned pin.jfr

On the class initializer case, the output leaves no room for doubt:

jdk.VirtualThreadPinned {
  startTime = 16:59:22.681 (2026-07-16)
  duration = 2.01 s
  blockingOperation = "LockSupport.park"
  pinnedReason = "VM call to Clinit$Slow.<clinit> on stack"
  carrierThread = "ForkJoinPool-1-worker-1" (javaThreadId = 31)
  eventThread = "init" (javaThreadId = 30, virtual)
  stackTrace = [
    java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 826
    ...
  ]
}

It’s all there: the duration, the reason, the blocked carrier, the guilty virtual thread and the stack. The pinnedReason field names the class and its <clinit>. Those three fields, blockingOperation, pinnedReason and carrierThread, were added by JEP 491, precisely so the event could replace the property it removed.

The other event worth knowing is jdk.VirtualThreadSubmitFailed, also enabled by default, which reports that a virtual thread couldn’t be started or unparked. It’s rare, and always a bad sign. jdk.VirtualThreadStart and jdk.VirtualThreadEnd, on the other hand, are disabled by default, and just as well: at one event per task, it adds up fast.

In a thread dump, they’re nearly invisible

Here’s a trap far more common than pinning, and you find it at the worst possible moment.

A classic thread dump, jstack or jcmd Thread.print, doesn’t list virtual threads. That’s deliberate: the classic dump is a flat list of platform threads, and nobody wants to read a flat list of a million entries. It does flag the virtual thread currently mounted on each carrier, and since JDK 24 it prints that thread’s stack too, which is what makes it useful:

"ForkJoinPool-1-worker-1" #27 [32771] daemon prio=5 cpu=4177.76ms elapsed=4.18s
   Carrying virtual thread #26
	at jdk.internal.vm.Continuation.run(java.base@25.0.1/Continuation.java:251)
	at java.lang.VirtualThread.runContinuation(java.base@25.0.1/VirtualThread.java:293)
	...
   Mounted virtual thread #26
	at Mounted.lambda$main$0(Mounted.java:4)

Note the detail that costs you: the virtual thread is identified by its number, #26, never by its name. And above all, only the mounted ones show up.

A mounted virtual thread is a virtual thread that’s working. The ones that are waiting are unmounted, so absent from the dump. Which is the vast majority. And which is, precisely, the ones you’re looking for when things are stuck. The reflex described in Reading a thread dump with jstack therefore goes blind exactly where you need it most.

The new dump takes over:

jcmd <pid> Thread.dump_to_file -format=json dump.json

It writes JSON, grouped by “thread container”, meaning by executor, and it doesn’t pause the application. Every virtual thread is in there, with its name, its state and its stack:

{
  "tid": "30",
  "virtual": true,
  "name": "order-0",
  "state": "TIMED_WAITING",
  "stack": [
    "java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:780)",
    ...
  ]
}

Two things to take away. The virtual field, first, which tells the two worlds apart.

The name, second. Virtual threads are unnamed by default. Thread.ofVirtual().start(...) and Executors.newVirtualThreadPerTaskExecutor() produce threads whose name is the empty string. They print as VirtualThread[#43]/runnable. And your dump becomes ten thousand lines of "name": "", which won’t get you far. Name them, the way you name a platform thread pool. The builder can number them for you:

Thread.ofVirtual().name("order-", 0).start(task);

Good news along the way, and here too the web is behind: Java 25 added locks to the JSON dump. That was the big limitation of earlier versions. You now get blockedOn, monitorsOwned and parkBlocker:

{
  "tid": "26", "virtual": true, "name": "vt-1", "state": "BLOCKED",
  "blockedOn": "java.lang.Object@149c6999",
  "monitorsOwned": [ { "depth": 0, "locks": ["java.lang.Object@6f9e29f6"] } ]
},
{
  "tid": "28", "virtual": true, "name": "vt-2", "state": "BLOCKED",
  "blockedOn": "java.lang.Object@6f9e29f6",
  "monitorsOwned": [ { "depth": 0, "locks": ["java.lang.Object@149c6999"] } ]
}

A textbook deadlock, two virtual threads taking two monitors in opposite order. Each is blocked on the object the other holds, and the addresses line up by eye. With one sizeable caveat: there is no automatic detection. The Found one Java-level deadlock from jstack doesn’t exist here, and ThreadMXBean.findDeadlockedThreads() doesn’t see cycles of virtual threads. The dump gives you the material, the joining up is on you.

Don’t pool virtual threads

This is the most common misunderstanding after pinning, and it comes from a good instinct.

A platform thread pool exists because a platform thread is expensive. Reckon on the order of a megabyte of reserved stack, 1 MB on Linux x64, 2 MB on macOS/aarch64. Plus an OS thread, plus a system call. You recycle them because they’re scarce.

A virtual thread is just an object on the heap. No system call. Its stack grows and shrinks as needed. Reckon on the order of a kilobyte: 200,000 virtual threads parked on a shallow stack fit in 196 MB.

Putting one in a pool therefore means paying the complexity of a pool to save a thousand times less than you think. JEP 444 has a section on this whose title doesn’t mince words: “Do not pool virtual threads”.

A virtual thread is made for one task, start to finish, then it dies. Hence the name of the JDK’s executor, which says exactly what it does, and whose thread count is unbounded:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var order : orders) {
        executor.submit(() -> process(order));
    }
}

That leaves the legitimate question behind the pooling instinct: limiting concurrency. A downstream API won’t take ten thousand parallel calls, and the pool of 20 threads doubled as a limiter. The answer isn’t to cap the threads, it’s to cap access to the resource:

private final Semaphore limit = new Semaphore(20);

public Response call(Request r) throws InterruptedException {
    limit.acquire();
    try {
        return client.send(r);
    } finally {
        limit.release();
    }
}

Twenty calls in flight at most, as many virtual threads as you like, and the limit sits where it makes sense. And it isn’t a trade-off: the two constructs are the same thing seen from two sides. A pool queues tasks waiting for a thread, a semaphore queues threads waiting for a permit. Since a virtual thread is the task, the resulting structure is equivalent. You lose nothing, you move the limit to where it can be understood.

The real ceiling is downstream

This is the point that spoils the most migrations. Virtual threads remove the cost of the thread. They remove no other limit.

The classic case: you switch virtual threads on in a Spring Boot application, one line does it,

spring.threads.virtual.enabled=true

and the HTTP pool stops being the bottleneck. Ten thousand concurrent requests now reach your code. There they find a HikariCP pool with 10 connections. Before, the 200-thread HTTP pool contained the damage upstream. Now the ten thousand queue up on getConnection, and latency explodes while the CPU sleeps and the database doesn’t break a sweat.

The bottleneck wasn’t removed, it was moved, and it became less visible. The symptom is exactly the one described in the thread dump article: everyone is waiting on getConnection. Except this time you need the JSON dump to see it.

The good news is that this ceiling is a healthy one. A database has a limited number of useful connections. Inflating it doesn’t make it faster. So the right move isn’t to take Hikari to 500 connections. It’s to choose the limit deliberately, with an acquisition timeout that rejects cleanly instead of making people wait. Virtual threads make explicit the limits the thread pool used to keep implicit.

And don’t add a Semaphore on top of the connection pool. A connection pool already is a semaphore: capped at ten connections, it blocks the eleventh caller asking for one. The limit is there, it does its job, it just needs setting.

Two details that sting on Spring Boot, while we’re here. First, the properties that used to size your thread pools stop applying, without warning. Which figures: there’s no dedicated pool anymore.

Second, virtual threads are daemon threads. So an application left with nothing but @Scheduled beans shuts itself down, for lack of a non-daemon thread to keep it alive. The cure is one line: spring.main.keep-alive=true.

Nothing to gain on CPU

Virtual threads are for code that waits. An HTTP call, a SQL query, a file read. The gain comes from a waiting thread no longer tying up an OS thread.

For computation there’s nothing to gain, and you can show it with numbers. VirtualThreadSchedulerMXBean, added in JDK 24, finally exposes the scheduler’s state. On a 10-processor machine, after starting 2000 virtual threads looping on arithmetic:

parallelism               = 10
poolSize                  = 10
mountedVirtualThreadCount = 10
queuedVirtualThreadCount  = 1990

Ten mounted, 1990 queued. A virtual thread doing computation holds its carrier from start to finish, exactly like a platform thread. A million virtual threads on ten cores is still ten cores. Worse: the scheduler does no time sharing, it won’t preempt a virtual thread that’s computing. The 1990 wait for the 10 to finish.

That MXBean has earned a place in your metrics, by the way. Watch for queuedVirtualThreadCount climbing while mountedVirtualThreadCount sits at parallelism. That’s the signature of a saturated scheduler. And it’s the signal we didn’t have until now.

It’s exposed under jdk.management:type=VirtualThreadScheduler. On the JMX side the attributes take a capital letter: QueuedVirtualThreadCount, MountedVirtualThreadCount. Handy if you wire it up to JConsole or an exporter.

The corollary stings: on an application that’s already fast and barely concurrent, virtual threads bring nothing measurable. They change the concurrency ceiling, not the latency of a single request.

The rest of the family

Three related topics, briefly.

ThreadLocal still works and isn’t deprecated. The nuance is finer than people say. Putting context in it is perfectly reasonable: the current user, a transaction id. Nobody will hold that against you.

What doesn’t fly anymore is the other use: caching expensive objects in it for reuse. That cache only held up because threads were scarce and recycled. With one thread per task, every virtual thread starts with an empty ThreadLocal. So the expensive object gets rebuilt for every task instead of being shared. The cache caches nothing.

The rest is a matter of volume. A million virtual threads means a million copies.

Scoped values, final in Java 25, answers the context need differently, read-only and with a bounded scope:

private static final ScopedValue<User> USER = ScopedValue.newInstance();

ScopedValue.where(USER, user).run(() -> process(order));

The value is visible to all the code called inside, and gone on the way out. No leak, no cleanup to do.

Structured concurrency is still in preview on Java 25. Its API moved again: StructuredTaskScope.open() replaced the constructor, and ShutdownOnFailure gave way to Joiner. It’s excellent. It will move again. Don’t put it in code you don’t feel like revisiting on every release.

In short

synchronized hasn’t pinned since JDK 24. The advice to swap it for ReentrantLock is out of date. jdk.tracePinnedThreads died with it.

The pinning that’s left comes from native code and class initialization. It reads in JFR, through jdk.VirtualThreadPinned, on by default above 20 ms.

On the diagnostic side, jstack only sees mounted virtual threads. Get into the habit of jcmd Thread.dump_to_file -format=json, which now gives you the locks. And name your virtual threads, or the dump will teach you nothing.

Don’t pool them. To cap concurrency, a Semaphore is enough. Watch queuedVirtualThreadCount.

And above all, look downstream. When you remove the thread bottleneck, the connection pool becomes the wall.

Virtual threads are a very good feature. Stable, and free of any major trap. The biggest risk they carry is following advice from two years ago.