Java Virtual Threads In Depth
A deeper look at Java virtual threads — pinning, scheduling, and how to migrate code from platform threads.
Java Virtual Threads In Depth
Platform threads — the only kind Java had until JDK 21 — map one-to-one onto operating-system threads. They are expensive: each reserves roughly a megabyte of stack and the OS scheduler can only juggle a few thousand before context-switching eats your CPU. Virtual threads, delivered by Project Loom, break that ceiling. They are lightweight threads managed by the JVM, not the OS, so a single program can run millions of them. This chapter goes past the introduction: how they are scheduled, what pinning is, how structured concurrency ties their lifetimes together, and where they help (and where they do not).
Platform threads vs. virtual threads
A virtual thread is still a java.lang.Thread — the same API, the same Runnable. The difference is what backs it. A platform thread is an OS thread for its whole life. A virtual thread runs on a small pool of platform threads called carriers: when it blocks on I/O, the JVM unmounts it from its carrier, frees that carrier for another virtual thread, and remounts the virtual thread later when the I/O completes. Blocking a virtual thread is cheap; blocking a platform thread wastes a scarce resource.
| Aspect | Platform thread | Virtual thread |
|---|---|---|
| Backed by | One OS thread | A pooled carrier thread |
| Memory cost | ~1 MB fixed stack | A few hundred bytes, grows on demand |
| Practical count | Thousands | Millions |
| Best for | CPU-bound work | I/O-bound, high-concurrency work |
| When it blocks | Wastes the OS thread | Unmounts; the carrier is reused |
| Lifecycle | Pool and reuse | Create one per task, throwaway |
The mental model flips. With platform threads you carefully size a pool and reuse threads. With virtual threads you create one per task and let it die — they are cheap enough to be disposable.
Creating virtual threads
There are three idiomatic entry points. For a single task use the Thread.ofVirtual() builder or the Thread.startVirtualThread shortcut; for many tasks use a virtual-thread-per-task executor.
// One-off, started immediately.
Thread t = Thread.startVirtualThread(() ->
System.out.println("hi from " + Thread.currentThread()));
t.join();
// Builder: configure before starting.
Thread named = Thread.ofVirtual().name("worker-", 0).unstarted(() -> doWork());
named.start();
// Many tasks: the executor creates a fresh virtual thread per submitted task.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000; i++) {
executor.submit(() -> handleRequest());
}
} // close() waits for every task to finishNever pool virtual threads. A traditional Executors.newFixedThreadPool(...) limits concurrency on purpose; wrapping virtual threads in a fixed pool throws away their whole advantage. The right tool is newVirtualThreadPerTaskExecutor(), which imposes no size limit.
Scheduling, carriers, and pinning
Virtual threads are scheduled by a dedicated ForkJoinPool whose worker count defaults to the number of CPU cores. Those workers are the carrier threads. When a virtual thread hits a blocking call in the JDK — Thread.sleep, socket reads, BlockingQueue.take — the runtime unmounts it so the carrier can run something else.
Sometimes a virtual thread cannot be unmounted and stays bolted to its carrier. This is pinning, and it defeats the purpose: a blocked-but-pinned virtual thread holds a carrier hostage. Two situations cause it:
| Cause of pinning | Why it happens | Fix |
|---|---|---|
Inside a synchronized block/method | The monitor is tied to the carrier | Replace with ReentrantLock |
| Inside a native (JNI) call | The runtime cannot capture the native stack | Avoid blocking in native code |
// Pins the carrier while sleeping — bad.
synchronized (lock) {
Thread.sleep(1000); // the virtual thread cannot unmount here
}
// Does not pin — good.
lock.lock();
try {
Thread.sleep(1000); // the virtual thread unmounts freely
} finally {
lock.unlock();
}You can diagnose pinning by running with -Djdk.tracePinnedThreads=full, which prints a stack trace whenever a virtual thread pins its carrier.
Structured concurrency
Spawning threads ad hoc leaks them: if one subtask fails, its siblings keep running and you have to remember to cancel them. Structured concurrency (StructuredTaskScope, a preview API) makes a group of subtasks behave like a single unit of work — they are forked together, joined together, and cancelled together. When the parent scope exits, all children are guaranteed to be done.
import java.util.concurrent.StructuredTaskScope;
Response handle() throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> fetchUser()); // subtask 1
var order = scope.fork(() -> fetchOrder()); // subtask 2
scope.join(); // wait for both
scope.throwIfFailed(); // propagate the first failure, cancel the rest
return new Response(user.get(), order.get());
} // both subtasks are guaranteed finished or cancelled here
}ShutdownOnFailure cancels the remaining subtasks the moment one throws; ShutdownOnSuccess returns as soon as the first subtask succeeds (handy for racing redundant calls). Either way there are no orphaned threads.
A worked example: ten thousand concurrent tasks
The program below submits 10,000 I/O-bound tasks — each just sleeps 50 ms to mimic a network call — to a virtual-thread-per-task executor. It counts how many distinct carrier threads actually ran the work and compares wall-clock time against running the same tasks one after another.
What to take from the run:
- 10,000 tasks complete, yet the whole run finishes in well under a second — nowhere near the ~500,000 ms the same sleeps would take run sequentially, because all the waiting overlaps.
- The carrier-thread count equals the number of CPU cores (
Carrier threadsmatchesAvailable cores): thousands of virtual threads are multiplexed onto that tiny handful of platform threads. Thread.sleepunmounts the virtual thread from its carrier, which is exactly why so few carriers can serve so many tasks at once — the carrier is never sitting idle waiting.- Closing the
newVirtualThreadPerTaskExecutor()in a try-with-resources block blocks until every submitted task finishes, so the completed count always reaches 10,000 before the timing is printed. isVirtual()returnstrueandisDaemon()returnstrue— virtual threads are always daemon threads, so they never keep the JVM alive on their own.
Practice
You wrap virtual threads in Executors.newFixedThreadPool(200) to run 10,000 I/O-bound tasks. Why is this a mistake?