Java Virtual Threads Model
In short:
VirtualThread (lightweight thread) — the code
↓
ForkJoinPool (scheduler)
↓
Carrier Thread (regular platform thread = OS thread)
↓
Mount/Unmount mechanism (mounted/unmounted)
ForkJoinPool is a pool of carrier threads. It is the one that decides which carrier thread will pick up the next virtual thread.
VirtualThread blocks (I/O, socket)
→ Detected via the JDK mechanism (re-implemented operations)
→ VirtualThread UNMOUNTS from the Carrier Thread
→ Continuation is saved (stack + state)
→ Carrier Thread starts executing ANOTHER VirtualThread from the queue
→ When I/O completes → the virtual thread is placed back into the ready queue
In detail:
Task scheduling: The scheduler (
ForkJoinPool) does not dispatch aVirtualThreadabstractly; it creates a task (Runnable) to execute this virtual thread's code and places it into the queue of a specificCarrier Thread. This is what scheduling is.Carrier's local queue: Yes, each
Carrier Threadin theForkJoinPoolhas its own queue —Deque<Runnable>. This is a key detail for Work-Stealing: when a thread becomes free, it checks its own queue first, and if it's empty, it tries to "steal" a task from another thread's queue.-
Task execution:
- The
Carrier Threadtakes a task from its queue. - This task is nothing else but the virtual thread's code.
- As soon as the
Carrierstarts executing this task, Mounting occurs — the virtual thread's state (its stack) is placed onto the actual stack of theCarrier Thread.
- The
-
Unmounting as a context switch:
- When the virtual thread performs a blocking operation,
unmount()is called. - The
Carrier Threadimmediately saves the current stack state of the virtual thread to the Heap. - Then, instead of waiting for the operation to complete, the
Carriergrabs the next task from its queue (or steals from a neighbor) and mounts a new virtual thread onto itself. - The original virtual thread itself transitions to a waiting state. Once the I/O completes, it will be placed back into the queue of some
Carrier Thread(possibly a different one) for re-mounting and resumption of work.
- When the virtual thread performs a blocking operation,
Java Virtual Threads use preemptive scheduling only at blocking points (I/O, synchronized, etc.). If a thread is busy with pure computations (CPU-bound), it will monopolize the carrier thread and will not be preempted until completion or forced blocking. If your code runs a loop for 10 seconds — it will occupy the carrier for the entire time. An infinite loop without I/O will pin (block) the carrier thread.
This is due to the fact that virtual threads in Java were designed primarily for highly loaded I/O-bound servers (spending 80-90% of the time waiting). For CPU-bound tasks inside virtual threads, it is recommended to either avoid them or insert artificial yield points, such as Thread.sleep(1) or Thread.yield(), to give other threads a chance to execute.
Virtual Thread
Let's trace the complete lifecycle of a Virtual Thread in Java from the moment of its creation until it is garbage collected.
Virtual Thread Creation
At this stage, the VirtualThread Java object is created. The JVM does not allocate a separate OS stack for it and does not bind it to any OS thread. Instead, the JVM allocates a small Continuation object in the heap, which will store the virtual thread's stack state when it is not executing. The initial size of this state is only a few hundred bytes.
There are several ways to create a virtual thread:
-
Thread.startVirtualThread(Runnable task): Creates and immediately starts a new virtual thread. This is the simplest way for one-off tasks. -
Thread.ofVirtual().start(Runnable task): A more flexible approach using aBuilder, allowing you to set the thread name, uncaught exception handler, and other parameters before starting. -
Executors.newVirtualThreadPerTaskExecutor(): Creates anExecutorServicethat spawns a new virtual thread for each task. This is the recommended way for server applications, as it allows easy management of a massive number of tasks.
Starting and Scheduling (State: NEW -> RUNNABLE)
When the .start() method is called, the virtual thread is not executed immediately. It is marked as ready for execution (RUNNABLE), and the JVM places it into the scheduler's queue. The virtual thread scheduler in Java is implemented on top of a ForkJoinPool. This pool operates on the work-stealing principle: each Carrier Thread has its own task queue, and if one thread finishes its tasks, it can "steal" a task from another thread's queue, which ensures high CPU core utilization.
The virtual thread's task is now ready to be executed. However, at this point, it is not yet bound to any OS thread.
Mounting onto a Carrier Thread (Mounting)
When one of the ForkJoinPool threads (which acts as the Carrier Thread — a regular OS platform thread) becomes free, it picks a ready virtual thread from the queue and mounts it onto itself.
The mounting process involves:
- Mount/Unmount scheme: Essentially, the JVM finds a free platform thread to "put" the virtual thread onto for execution.
- State restoration (Thawing): The JVM retrieves the previously saved virtual thread stack (the Continuation object) from the heap and "thaws" it, transferring it to the actual stack of the carrier thread.
- Execution start: After this, the code inside the virtual thread begins to execute.
The virtual thread is now in the RUNNING state.
Execution and Blocking Operations
While the virtual thread code is executing computations, everything works just like with a regular thread. The magic happens at the moment of a blocking operation (e.g., waiting for a database response, calling Thread.sleep(), reading from a socket). At this point, the key mechanism of virtual threads triggers — unmounting:
- Blocking detection: The JVM intercepts the blocking method call.
-
Freezing: The JVM saves the current execution stack state of the virtual thread (all local variables, program counter, etc.) into a special
Continuationobject in the heap and "freezes" it. - Carrier release: The virtual thread unmounts from its carrier thread. The carrier thread is no longer busy and returns to the ForkJoinPool, ready to execute the next virtual task.
- Transition to PARKING state: The virtual thread itself transitions to the PARKING (waiting) state.
It is important to note that during its lifetime, a virtual thread can be mounted on different carrier threads. Right after waking up, it might end up on a completely different Carrier Thread from the pool.
Special Case: Pinning
In some cases, a virtual thread cannot be unmounted during a blocking operation and is forced to wait, occupying the carrier thread. This is called pinning. This happens under the following conditions:
-
Inside a
synchronizedblock or method: Acquiring asynchronizedmonitor pins the virtual thread to the carrier. - When executing a native method (JNI) or foreign function: The JVM cannot safely "freeze" a native stack.
Such behavior can significantly reduce scalability since the carrier thread gets blocked. Fortunately, in Java 24, this issue was resolved as part of JEP 491: Synchronize Virtual Threads without Pinning. In newer versions, synchronized no longer causes pinning; this became possible thanks to internal optimizations that allow virtual threads to correctly unmount even inside synchronized blocks.
To find problematic spots in code on Java 21, you can use the flag
-Djdk.tracePinnedThreads=full
Waking Up and Resuming (Unparking)
When the operation on which the virtual thread was blocked completes (e.g., a DB response arrives), the wake-up mechanism triggers:
- Readiness signal: The JVM receives a notification that the resource has become available.
- Unparking: The JVM transitions the virtual thread from the PARKING state to the RUNNABLE state (ready to execute).
-
Enqueuing: The JVM places the
Continuationof this woken thread back into theForkJoinPoolqueue for scheduling. -
Re-mounting: As soon as one of the pool's carrier threads becomes free, it "thaws" the
Continuationand mounts the virtual thread to resume execution, possibly on a different carrier.
Thus, while the virtual thread was waiting for a DB response for 2 seconds, the carrier thread on which it was originally executing managed to process thousands of other virtual tasks, which drastically increases the overall throughput of the application.
Termination and Disposal (Garbage Collection)
A virtual thread can terminate naturally when the run() method of its task completes, or forcibly if the interrupt() method is called during waiting or execution. After the thread terminates:
- The virtual thread transitions to the TERMINATED state.
- Its association with the
Continuationobject is broken. - Garbage Collection: The thread object itself and its
Continuation(the heap-allocated stack) no longer have strong references from GC Roots (e.g., from theForkJoinPool). Therefore, during the next garbage collection cycle, they can be collected. - Crucial difference from regular threads: A virtual thread's stack is stored in the heap and is not a GC root. This is a critically important optimization: creating and destroying millions of virtual threads does not create a massive load on the garbage collector, since their stacks do not need to be scanned every time during collection.
Takeaways
- Thanks to storing the stack in the heap, you can create millions of virtual threads that collectively will occupy significantly less memory than a few thousand standard threads.
- Do not pool virtual threads: Creating a virtual thread is a very cheap operation. They do not need to be cached, as was the case with platform threads. On the contrary, create a new thread for each task.
-
Be careful with ThreadLocal: If you still use
ThreadLocalwith virtual threads, remember that they live exactly as long as the virtual thread lives. A virtual thread pool is an anti-pattern, so an object pool viaThreadLocalalso becomes meaningless and can lead to memory leaks. As a modern alternative for context propagation, you can useScopedValue. -
Track Pinning: On Java 21, use
-Djdk.tracePinnedThreadsto identify places where virtual threads are pinned to the carrier. This will help ensure you are getting the maximum benefit from using them.

Top comments (0)