Threads & CPU

Virtual threads in production: JEP 444, pinning and what to monitor

Threads & CPU · Published 2026-10-24

A heap of colourful sewing thread spools
Virtual threads are cheap enough to have one per task. Photo: rawpixel, CC0.

Virtual threads, final since JDK 21, let a server run one thread per request even when requests number in the hundreds of thousands. They change capacity planning and a few habits, but they do not change the thread dump, the debugger or the programming model. This guide covers what matters when you run them in production.

What a virtual thread is

JEP 444 describes a virtual thread as an instance of java.lang.Thread that is not tied to a particular OS thread. The JDK schedules virtual threads onto a small pool of platform threads called carriers. When a virtual thread blocks on I/O, a lock or a sleep in a way the JDK supports, it is unmounted from its carrier, which is then free to run another virtual thread. When the blocking operation completes, the virtual thread is scheduled again, possibly on a different carrier.

The scheduler is a work-stealing ForkJoinPool in FIFO mode. Per the JEP, its parallelism defaults to the number of available processors and can be changed with the system property jdk.virtualThreadScheduler.parallelism; jdk.virtualThreadScheduler.maxPoolSize sets the maximum number of platform threads available to the scheduler.

Creating them

// one virtual thread per task
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
    for (Request r : requests) {
        exec.submit(() -> handle(r));
    }
}   // close() waits for submitted tasks

// or directly
Thread t = Thread.ofVirtual().name("worker-", 0).start(() -> handle(req));

Many frameworks and servers can switch their request handling to virtual threads with a configuration option; check your framework's documentation rather than wrapping its executors yourself.

When they help, and when they do not

Virtual threads raise throughput for workloads that spend most of their time waiting: calls to databases, HTTP services, queues. With platform threads, the number of requests in flight is capped by the number of threads you can afford. With virtual threads, that cap mostly disappears.

They do not make code run faster. A CPU-bound task gets no benefit, since the number of carriers is about the number of cores. The JEP is explicit that virtual threads are not faster threads: they offer scale, not speed.

Do not pool them

The JEP states that virtual threads are cheap and plentiful and should never be pooled; a new virtual thread should be created for every application task. A pool of N virtual threads brings back exactly the limitation they remove.

Pools were often used to limit concurrency, for example to keep at most 20 simultaneous calls to a fragile service. With virtual threads, use a construct designed for that, as the JEP recommends, such as a Semaphore:

private final Semaphore permits = new Semaphore(20);

Result callBackend(Query q) throws InterruptedException {
    permits.acquire();
    try {
        return backend.call(q);
    } finally {
        permits.release();
    }
}

The same thinking applies downstream: a connection pool sized for 200 platform threads now receives demand from thousands of virtual threads. Expect waiting at the pool and size it, and the database behind it, deliberately.

Pinning

Sometimes a blocked virtual thread cannot be unmounted, so it holds its carrier while it waits. This is called pinning. JEP 444 lists two cases in JDK 21: code running inside a synchronized block or method, and code running a native method or a foreign function. Pinning does not break correctness, the JEP notes, but it can hurt scalability: if all carriers are pinned by blocked threads, no other virtual thread can run.

JDK 21 to 23

In these releases, blocking inside synchronized (for example, doing I/O while holding a monitor) pins. The JEP's advice is to replace synchronized with ReentrantLock where code blocks for a long time and runs frequently, not to rewrite every synchronized block. To find the places, JDK 21 offers the system property jdk.tracePinnedThreads (values full or short), which prints a stack trace when a thread blocks while pinned.

JDK 24 and later

JEP 491, delivered in JDK 24, lets virtual threads acquire, hold and release monitors without pinning, so blocking inside synchronized no longer pins in most cases. The JEP lists a few remaining cases, related to class loading and class initialisation and to native code, and it removed the jdk.tracePinnedThreads property: setting it has no effect. If pinning was your main reason to avoid virtual threads, upgrading may remove it.

ThreadLocal and memory

Thread-local variables work in virtual threads, but the JEP advises using them only after careful consideration, because there can be very many virtual threads, and warns specifically against using them to cache expensive objects per thread, a pattern that made sense only with pooled threads. Each virtual thread with its own copy of a large cached object multiplies memory. Scoped values are presented as a better fit for some use cases.

Virtual thread stacks live in the heap and grow and shrink as needed. A million parked virtual threads with deep stacks therefore show up as heap usage: watch the heap, not thread counts.

Observing virtual threads

Thread dumps

jcmd Thread.print lists platform threads, including carriers. For a dump that includes virtual threads, the JEP introduced a new command, with plain text or JSON output:

$ jcmd <pid> Thread.dump_to_file -format=json /var/tmp/threads.json

The JEP notes that generating this dump does not pause the application, and the JSON form is meant to be processed by tools, which helps when there are very many threads. See reading thread dumps for interpreting stacks.

Flight Recorder events

JEP 444 lists the JFR events:

JEP 491 notes that from JDK 24, jdk.VirtualThreadPinned is no longer recorded for blocking in synchronized, but still covers the remaining pinning cases and reports the reason and the carrier. A continuous recording (see the JFR guide) will show pinning without any extra setup.

Migration checklist

Sources