skip to content

How does a virtual thread actually execute? Explain mounting, unmounting, and carrier threads.

level: seniorimportance: must knowfreq 70%

answer

  1. Carrier = real platform/OS thread from a (default) ForkJoinPool
  2. Mount = run virtual thread on a carrier; unmount on blocking
  3. Unmount copies the continuation (stack) to the heap, frees carrier
  4. Remount may use a different carrier; resumes after the blocking call
  5. Pinning: synchronized/native blocks the carrier — prefer ReentrantLock

basics

~20 s

A virtual thread doesn't have its own OS thread. To run, the JVM mounts it onto a carrier (a real platform thread). When it blocks, the JVM unmounts it and stores its stack on the heap, freeing the carrier for other virtual threads.

solid answer

~50 s

A virtual thread is scheduled by the JVM, not the OS. To execute, the JVM mounts it onto a carrier thread — a platform (OS) thread from a pool, by default a dedicated ForkJoinPool. While mounted, the virtual thread's stack frames live on the carrier's native stack and it runs like ordinary code. When it hits a blocking call that the JDK has instrumented (e.g. blocking socket or file I/O, locks, sleep), the JVM unmounts it: it copies the continuation — the virtual thread's stack and execution state — onto the heap, and releases the carrier to run a different virtual thread. When the awaited event completes, the scheduler remounts the virtual thread, possibly onto a different carrier, restoring its stack, and it resumes right after the blocking call. So one carrier multiplexes many virtual threads: while some wait, others run. The number of carriers is small (defaults to the CPU core count), while virtual threads can number in the millions.

code

java · 12 lines
java
// Each task is a virtual thread; the executor mounts/unmounts them on a few carriers.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000_000; i++) {
        executor.submit(() -> {
            // Plain BLOCKING code. On this blocking call the JVM unmounts
            // the virtual thread (stack -> heap) and frees the carrier.
            String body = fetch("https://service/internal"); // blocks -> unmount
            process(body);                                    // resumes after I/O (maybe new carrier)
            return null;
        });
    }
} // close() waits for all 1,000,000 virtual threads to finish

go deeper

for a junior

Knows a virtual thread runs on top of a real platform thread and that it 'steps aside' when it blocks.

for a middle

Describes mounting onto a carrier and unmounting on blocking, and that carriers are few while virtual threads are many.

for a senior

Explains the continuation being moved to the heap on unmount, remounting on a possibly-different carrier resuming after the blocking call, and names the default ForkJoinPool scheduler.

for a principal

Discusses pinning causes and mitigations, scheduler fairness (FIFO vs work-stealing), carrier-pool sizing/tuning, and how library choices (synchronized, JNI, thread-locals) affect whether the mechanism actually scales.

## Two kinds of thread A **platform thread** is a Java thread mapped one-to-one to an **OS thread** — the unit the operating system schedules onto CPU cores. A **virtual thread** is a Java thread scheduled by the **JVM** (the Java runtime), and it is **not** permanently tied to any OS thread. This section explains the machinery that lets virtual threads run on a tiny number of OS threads. ## The carrier thread A virtual thread cannot touch a CPU by itself; only OS threads run on cores. So to execute, a virtual thread is **mounted** onto a **carrier thread** — an ordinary **platform (OS) thread** drawn from a pool. By default this pool is a dedicated **ForkJoinPool** (a work-stealing thread pool from `java.util.concurrent`) whose size defaults to the number of available processor cores. Think of carriers as a small set of 'workers' and virtual threads as a huge backlog of 'jobs' that take turns on those workers. ## Mounting **Mounting** means: the JVM picks an idle carrier and runs the virtual thread on it. While mounted, the virtual thread's **stack frames** (the per-method records of local variables and the call chain) sit on the carrier's native stack, and the code executes exactly like normal Java. From the CPU's point of view, the carrier is just running code. ## Unmounting on blocking The key trick happens when the virtual thread does something **blocking** — an operation that would normally make a thread wait, such as reading from a network socket, sleeping, or waiting on a lock. The JDK's blocking operations have been **rewritten** so that, instead of blocking the carrier, they signal the JVM to **unmount** the virtual thread: 1. The JVM captures the virtual thread's current state — its stack and program position — as a **continuation** (a saved, resumable snapshot of an execution). 2. That continuation is **copied from the carrier's stack onto the heap** (the JVM's general-purpose memory). This is why an idle virtual thread costs only a small, growable chunk of heap rather than a full ~1 MB OS-thread stack. 3. The **carrier is released** back to the pool, free to mount and run a *different* virtual thread. So a blocking call no longer wastes an OS thread: the OS thread (carrier) goes off and does other useful work while the virtual thread 'waits' as inert heap data. ## Remounting and resuming When the awaited event finishes (the bytes arrive, the sleep elapses, the lock frees), the JVM scheduler **remounts** the virtual thread — possibly onto a **different** carrier than before. The continuation is restored from the heap back onto a carrier's stack, and execution **resumes at the exact instruction after the blocking call**. To your code it looks like an ordinary blocking call that simply returned; the unmount/remount is invisible. ## The default scheduler The default scheduler is a **FIFO ForkJoinPool**: when a virtual thread becomes runnable it is queued, and carriers pull work in roughly first-in-first-out order. This is a different default from the LIFO/work-stealing mode ForkJoinPool uses for fork/join tasks, chosen for fairness among many independent virtual threads. The scheduler is **non-preemptive** for virtual threads at the JVM level: a virtual thread yields the carrier only at blocking points (and certain safepoints), not on an arbitrary timer. ## Pinning (the exception) Sometimes a virtual thread **cannot** be unmounted while blocked — it stays **pinned** to its carrier, tying up that OS thread. The classic cause: blocking **inside a `synchronized` block/method** (a `synchronized` region holds a monitor in a way that historically couldn't be unmounted), or calling **native (JNI) code**. Pinning defeats the benefit, so the guidance is to prefer `java.util.concurrent.locks.ReentrantLock` over `synchronized` around blocking calls. (Later JDKs reduce `synchronized` pinning, but the principle remains exam-relevant.) ## Putting it together A handful of carriers (≈ core count) **multiplex** millions of virtual threads: at any instant only a few run, the rest are parked as heap continuations. Because unmounting frees the carrier on every block, blocking code scales — exactly the property thread-per-request needs.

  • What is 'pinning' and what commonly causes it?
    Pinning is when a blocked virtual thread cannot unmount and keeps occupying its carrier OS thread, defeating scalability. Classic causes are blocking inside a synchronized block/method or while in native (JNI) code. Mitigation: use ReentrantLock instead of synchronized around blocking calls.
  • Why is the default scheduler FIFO rather than the LIFO work-stealing mode ForkJoinPool normally uses?
    FIFO gives fairness across many independent virtual threads so none starves, which suits server-style thread-per-request workloads. LIFO work-stealing favors cache locality for recursive fork/join tasks, a different goal.

saying these in an interview costs you the question

  • Saying each virtual thread keeps its own OS thread while blocked
  • Claiming the carrier stays busy/blocked during the virtual thread's wait
  • Confusing the carrier pool size (≈ cores) with the number of virtual threads (millions)
  • Forgetting pinning: assuming all blocking always unmounts cleanly

context