JVM Scope

Virtual Threads and the Pinning Problem

Virtual threads scale until something nails one to its carrier. Here is what still pins on modern JDKs, and how to see it happen.

5 min read
Virtual Threads and the Pinning Problem — Concurrency article cover

The pitch for virtual threads is simple enough to be misleading: write blocking code, get non-blocking scalability. It holds most of the time. When it does not, the cause is almost always the same mechanism failing in the same way, and the failure is invisible unless you know which signal to read.

What mounting actually means

A virtual thread is not scheduled by the operating system. It is a continuation — a heap-allocated object holding a stack — that runs on a carrier thread, which is an ordinary platform thread taken from a dedicated ForkJoinPool. By default that pool has as many carriers as you have processors.

Running a virtual thread means mounting it: copying its stack frames onto the carrier and jumping in. When the virtual thread hits something that blocks — a socket read, a BlockingQueue.take(), a Thread.sleep() — the JDK’s rewritten blocking primitives do not park the carrier. They copy the virtual thread’s frames back to the heap, unmount it, and hand the carrier to whichever virtual thread is next. When the IO completes, the continuation is resubmitted and mounted again, possibly on a different carrier.

This is the entire trick. A million virtual threads work because at any instant only a handful are mounted, and the blocked ones cost a stack in the heap rather than a thread in the kernel.

Everything about virtual thread performance follows from one question: can this thread unmount here?

When it cannot

Pinning is the state where a virtual thread blocks without being able to unmount. The carrier goes with it, blocked in the kernel, unavailable to any other virtual thread. With the default of one carrier per processor, a handful of simultaneously pinned threads can stall an application that appears to have thousands of threads available.

On JDK 21 through 23, the dominant cause was the synchronized keyword. Object monitors were tied to the carrier’s stack, so a virtual thread that blocked while holding — or waiting for — a monitor could not be unmounted. This is what produced the standard early advice to replace synchronized with ReentrantLock, which is implemented on top of LockSupport.park() and unmounts cleanly.

JDK 24 changed this. JEP 491 reimplemented monitors so that a virtual thread blocking inside a synchronized block or method, or inside Object.wait(), releases its carrier like any other blocking operation. On a current JDK, the most-repeated piece of virtual thread advice is no longer the most important one.

What still pins, on JDK 24 and later, is a short list — the JVM’s own Pinned reasons are NATIVE, CRITICAL_SECTION and EXCEPTION:

  • Native frames. A virtual thread with a native frame on its stack cannot have that stack relocated. The JEP names the case precisely: native code reached through a native method or the Foreign Function & Memory API, which then calls back into Java and blocks there. Legacy drivers with native components are the common real-world example.
  • JVM-internal critical sections, and the degenerate cases where the JVM cannot unmount at all because it is out of memory or out of stack.

Separately — not pinning, but the same symptom — anything that blocks without going through the JDK’s blocking primitives never unmounts. A busy-wait loop is the clearest case: it never yields, so its carrier is occupied for as long as it spins.

Seeing it instead of guessing

The old flag, -Djdk.tracePinnedThreads, was removed in JDK 24 along with the pinning it was written to diagnose — setting it now has no effect. The replacement is a JFR event, which is better in every way: it is cheap enough to leave on, it carries a stack trace, and JEP 491 extended it to record both the reason for the pinning and the identity of the carrier thread (pinnedReason, blockingOperation, carrierThread).

java -XX:StartFlightRecording:jdk.VirtualThreadPinned#enabled=true,\
filename=app.jfr,settings=profile -jar app.jar

Then read what it captured:

jfr summary app.jfr
jfr print --events jdk.VirtualThreadPinned app.jfr

Two other events belong in the same recording. jdk.VirtualThreadSubmitFailed fires when the scheduler could not accept a continuation, which is the signal that your carriers are saturated. jdk.VirtualThreadStart and jdk.VirtualThreadEnd are disabled by default for a reason — on a workload creating millions of them, they will dominate the recording.

A thread dump also works, and on JDK 21 and later there is a format designed for this:

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

The JSON dump groups virtual threads by the structured concurrency scope that created them and shows which carrier each mounted thread is on. Grepping a plain text dump for a million virtual threads is not a workable diagnostic; this is.

The anti-patterns that survive the fix

Three habits carried over from platform threads still cause trouble, none of which JEP 491 addresses.

Pooling them. A virtual thread is cheap to create and its stack grows on demand. Pooling reintroduces the resource ceiling that virtual threads exist to remove, and it defeats ThreadLocal cleanup. Use Executors.newVirtualThreadPerTaskExecutor(), or a StructuredTaskScope, and create one per task.

Using them for CPU-bound work. There are as many carriers as processors. Ten thousand virtual threads doing arithmetic will not run faster than ten platform threads doing the same arithmetic; they will just take longer to schedule. Virtual threads are a concurrency mechanism, not a parallelism one.

Removing the limit along with the pool. A fixed thread pool was, incidentally, a rate limiter on the resource behind it. Replace it with unbounded virtual threads and your database will receive ten thousand simultaneous connection requests. The explicit form of that limit is a Semaphore around the call, which is clearer than a pool size and does not block a carrier while waiting.

In a Spring Boot application

Spring Boot 3.2 and later reduce the whole migration to one property:

spring.threads.virtual.enabled=true

This switches the Tomcat request executor, @Async execution and Spring’s scheduling infrastructure onto virtual threads. It is a genuine one-line change for a request-per-thread web application, and it removes the tuning exercise around server.tomcat.threads.max entirely.

What it does not do is audit your dependencies. Before turning it on, look for native database drivers, anything that pools threads internally, and libraries that use ThreadLocal as a request-scoped cache — the last of these still works, but a per-task thread means a per-task cache, and a cache with a one-request lifetime is not a cache.

Then enable the JFR events and watch a real workload. The mechanism is simple enough to reason about, but which of your dependencies blocks in a native frame is not a question that reasoning answers.

Frequently asked

Should I still avoid synchronized with virtual threads?
On JDK 24 and later, blocking inside a synchronized block no longer pins the carrier, so the rewrite to ReentrantLock is no longer required for that reason alone. On JDK 21 through 23 it remains a real limit and the rewrite is worth doing.
Do virtual threads make my application faster?
They raise the number of concurrent blocking operations a process can hold open, which helps throughput on IO-bound workloads. They do not make any individual operation faster, and they do nothing for CPU-bound work.
Share

Related articles

Arrow keys to move, Enter to open.