W3docs

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.

AspectPlatform threadVirtual thread
Backed byOne OS threadA pooled carrier thread
Memory cost~1 MB fixed stackA few hundred bytes, grows on demand
Practical countThousandsMillions
Best forCPU-bound workI/O-bound, high-concurrency work
When it blocksWastes the OS threadUnmounts; the carrier is reused
LifecyclePool and reuseCreate 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 finish

Never 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 pinningWhy it happensFix
Inside a synchronized block/methodThe monitor is tied to the carrierReplace with ReentrantLock
Inside a native (JNI) callThe runtime cannot capture the native stackAvoid 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.

java— editable, runs on the server

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 threads matches Available cores): thousands of virtual threads are multiplexed onto that tiny handful of platform threads.
  • Thread.sleep unmounts 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() returns true and isDaemon() returns true — virtual threads are always daemon threads, so they never keep the JVM alive on their own.

Practice

Practice

You wrap virtual threads in Executors.newFixedThreadPool(200) to run 10,000 I/O-bound tasks. Why is this a mistake?