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

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:
jdk.VirtualThreadStartandjdk.VirtualThreadEnd, disabled by default;jdk.VirtualThreadPinned, enabled by default with a 20 ms threshold, recorded when a virtual thread parks while pinned;jdk.VirtualThreadSubmitFailed, enabled by default, when starting or unparking a virtual thread fails.
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
- Confirm the workload is I/O-bound; virtual threads will not fix CPU saturation (find the hot thread first).
- Replace fixed-size pools used for request handling with one virtual thread per task.
- Express concurrency limits with semaphores, and resize connection pools.
- On JDK 21 to 23, look for long blocking inside
synchronizedon hot paths; on JDK 24 and later, check the remaining pinning cases viajdk.VirtualThreadPinned. - Review
ThreadLocalcaches. - Practise taking a
Thread.dump_to_filedump before you need one.
Sources
- JEP 444: Virtual Threads
- JEP 491: Synchronize Virtual Threads without Pinning
- Java Core Libraries: Virtual Threads
- Semaphore javadoc
Threads & CPUReading thread dumps
Take a series, read states and lock lines, and let the JVM point out deadlocks for you.
Threads & CPUHigh CPU: find the thread
From top -H to the nid in a thread dump: a repeatable way to name the code that is burning CPU.
ProductionFlight Recorder and Mission Control
Continuous recording, on-demand dumps with jcmd, the events that matter, and reading .jfr files.