# 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.

- Published: 2026-07-23
- Tags: concurrency, virtual-threads, java, spring
- Source: https://jvmscope.com/blog/virtual-threads-and-the-pinning-problem/
- Language: en-US
- Author: Marcus Venn

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

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