virtual threads, inside
continuations, mounting, unmounting, and pinning.
- chapter
- chapter 17.10
- reading time
- 10 min read
a virtual thread is three things: a java.lang.VirtualThread object, a continuation (a piece of code you can pause and resume), and a scheduler (a ForkJoinPool of carrier threads). mount means running the continuation on a carrier. when it blocks, the continuation yields: its frames are frozen into a StackChunk object on the heap and the carrier moves on.
if the frames can't be moved (a VM frame like a class initializer, or native code on the stack), the thread blocks on its carrier instead. that's pinning.
the picture
you know how virtual threads make blocking "cheap", and that sounds like it can't possibly be true? the trick is that nothing actually blocks. the thread's stack gets packed up and put away, and the OS thread underneath goes off to do something else.
think of reading at a library with only a few desks. you don't own a desk (a carrier). when you have to wait for a book from the stacks (I/O), you put a bookmark in, pack your notes into a folder on the shelf (the heap), and give the desk to someone else. when the book arrives, you take any free desk, open the folder, and carry on from the bookmark. the bookmark is the continuation. the folder is the stack chunk.
a continuation, by hand
the JDK has the primitive itself, jdk.internal.vm.Continuation. it's internal (you need --add-exports to touch it, and you should never use it in real code), but it shows the whole idea in a dozen lines: run() runs until the code calls yield, and the next run() picks up right after that yield, locals and all.
import jdk.internal.vm.Continuation;
import jdk.internal.vm.ContinuationScope;
// the primitive under every virtual thread: a function you can pause and resume.
public class Gen {
public static void main(String[] args) {
var scope = new ContinuationScope("demo");
var c = new Continuation(scope, () -> {
for (int i = 1; i <= 3; i++) {
System.out.println(" inside: step " + i);
Continuation.yield(scope); // freeze the frames, return from run()
}
System.out.println(" inside: done");
});
while (!c.isDone()) {
System.out.println("outside: run()");
c.run(); // thaw the frames, continue after yield
}
}
}a virtual thread is that, plus a scheduler that calls run() for you on whichever carrier is free, plus a LockSupport.park() that calls yield instead of blocking the OS thread. every blocking API in the JDK ends in a yield: sockets, Thread.sleep, locks and BlockingQueue through that park, and monitors (since java 24) through the JVM itself.
mounting and unmounting
three virtual threads started. each is a Continuation plus some bookkeeping, waiting in the scheduler's queue. two carrier threads, the platform threads of a ForkJoinPool.
freezing is a memory copy: the frames between the continuation's entry point and the yield are copied off the carrier's stack into a StackChunk. thawing copies them back, but lazily: only the top frames go back eagerly, and a "return barrier" thaws the next ones as the code returns into them. so mounting a thread that's 500 frames deep doesn't copy 500 frames, which is what keeps it cheap.
stack chunks are ordinary heap objects. the GC traces them like any other object (with a special walker that knows where the references are inside the frames), so a parked virtual thread's locals keep their objects alive, exactly like a platform thread's stack does.
the state machine
VirtualThread keeps one int of state, and every mount, park and wake-up is a transition with a compare-and-set. here's the park path, with the method that makes each move:
a VirtualThread object and its continuation exist. nothing is scheduled.
yieldContinuation() returned true.stripped of JVMTI hooks and timeouts, the heart of it is this (paraphrased from jdk 25's VirtualThread.park()):
void park() {
if (parkPermitAvailable() || interrupted) return; // nothing to wait for
setState(PARKING);
boolean yielded = yieldContinuation(); // freeze to the heap, free the carrier
if (!yielded) { // couldn't freeze: pinned
setState(RUNNING);
parkOnCarrierThread(); // block the carrier itself
}
}that if (!yielded) is the entire difference between a virtual thread and a platform thread in a trench coat.
what a parked thread costs
i parked 10,000 virtual threads at different stack depths and asked jcmd GC.class_histogram what they cost. besides the chunk, every thread is a VirtualThread (168 bytes) and its continuation (72 bytes in two objects).
a deep stack still costs memory, it just costs heap instead of an OS thread. and heap is collected, resized and visible in a heap dump, which a thread stack never was.
the scheduler
the default scheduler is a ForkJoinPool in FIFO mode (asyncMode = true), separate from the common pool. you can ask a running JVM about it:
java.util.concurrent.ForkJoinPool@7e59d0b1[Running, parallelism = 14, size = 14, active = 0, running = 0, steals = 10293, tasks = 0, submissions = 0, delayed = 10000]that's a program with 10,000 virtual threads in Thread.sleep and 100 blocked on socket reads, on a 14-core mac. 14 carriers, none of them busy. the 10,000 sleepers are delayed tasks: on jdk 25, ForkJoinPool implements ScheduledExecutorService itself, and a sleeping virtual thread is just a wake-up task scheduled in its pool. the socket readers are somewhere else:
Read I/O pollers:
[0] sun.nio.ch.KQueuePoller@5d666d5b [registered = 100, owner = Thread[#10141,Read-Poller,5,InnocuousThreadGroup]]
Write I/O pollers:
[0] sun.nio.ch.KQueuePoller@7f51c72b [registered = 0, owner = Thread[#10142,Write-Poller,5,InnocuousThreadGroup]]a socket read with no data registers the socket with a poller (kqueue on macOS, epoll on linux) and parks. one Read-Poller platform thread waits on all 100 sockets at once and unparks each thread when its socket is ready. the knobs, all system properties read once at startup:
| property | default | what it does |
|---|---|---|
jdk.virtualThreadScheduler.parallelism | number of cores | carriers that run at the same time |
jdk.virtualThreadScheduler.maxPoolSize | max(parallelism, 256) | ceiling when the pool adds carriers to compensate for blocking file I/O or pinning |
jdk.virtualThreadScheduler.minRunnable | max(parallelism / 2, 1) | how few runnable carriers the pool tolerates before compensating |
pinning, caught in the act
java 24 (JEP 491) taught monitors to work with virtual threads: blocking to enter a synchronized block, holding one while you block, and Object.wait() all unmount now. what still pins, per the JEP: blocking inside a class initializer, waiting for another thread to initialize a class, blocking while the JVM resolves (loads) a class, and native code (JNI or a foreign function) that calls back into java which then blocks. here's the first one:
public class Pin {
static class Config {
static final String VALUE = load();
static String load() {
try { Thread.sleep(200); } catch (InterruptedException e) { } // slow init: a file, a network call...
return "loaded";
}
}
public static void main(String[] args) throws Exception {
Thread.ofVirtual().start(() -> System.out.println(Config.VALUE)).join();
}
}run it with flight recorder on, and JFR names the reason:
jdk.VirtualThreadPinned {
duration = 210 ms
blockingOperation = "LockSupport.park"
pinnedReason = "VM call to Pin$Config.<clinit> on stack"
carrierThread = "ForkJoinPool-1-worker-1" (javaThreadId = 31)
eventThread = "" (javaThreadId = 30, virtual)
stackTrace = [
java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 833
java.lang.VirtualThread.parkNanos(long) line: 801
java.lang.VirtualThread.sleepNanos(long) line: 983
...<clinit> is called by the JVM, not by java code, so there's a VM frame in the middle of the stack that freeze can't copy. the event fires for pins longer than 20 ms by default. for one slow config class at startup it doesn't matter. for a static initializer that every request might trigger, behind a 1-carrier scheduler in a small container, it does.
why not just make the JVM preempt and move native frames too?+
a native frame holds raw pointers into itself (C code can take the address of a local and store it anywhere), so its memory can't be moved without breaking them. java frames are different: the JIT and interpreter know exactly where every reference and every saved value lives, which is what lets freeze copy them and the GC walk them in a chunk. class initialization is pinned because the JVM's own C++ code for running <clinit> is on the stack. JEP 491 removed the biggest case (monitors were implemented in C++ too, and had to be rewritten so a monitor's owner can be a virtual thread rather than a carrier).
- virtual thread =
VirtualThread+Continuation+ a FIFOForkJoinPoolof carriers. - blocking means park, park means yield: frames frozen into a heap
StackChunk, carrier freed. - thaw is lazy: the top frames eagerly, the rest as the code returns into them.
- a parked thread costs about 1.5 KB of heap when shallow, more per frame. a platform thread, ~100 KB of RAM.
- since java 24 monitors don't pin. class initializers and native frames still do. JFR shows each one.
- no time slicing: a CPU-bound virtual thread keeps its carrier until it parks.