DEV Community

Cover image for Java Virtual Threads
Silver_dev
Silver_dev

Posted on

Java Virtual Threads

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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. Task scheduling: The scheduler (ForkJoinPool) does not dispatch a VirtualThread abstractly; it creates a task (Runnable) to execute this virtual thread's code and places it into the queue of a specific Carrier Thread. This is what scheduling is.

  2. Carrier's local queue: Yes, each Carrier Thread in the ForkJoinPool has 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.

  3. Task execution:

    • The Carrier Thread takes a task from its queue.
    • This task is nothing else but the virtual thread's code.
    • As soon as the Carrier starts executing this task, Mounting occurs — the virtual thread's state (its stack) is placed onto the actual stack of the Carrier Thread.
  4. Unmounting as a context switch:

    • When the virtual thread performs a blocking operation, unmount() is called.
    • The Carrier Thread immediately saves the current stack state of the virtual thread to the Heap.
    • Then, instead of waiting for the operation to complete, the Carrier grabs 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.

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 a Builder, allowing you to set the thread name, uncaught exception handler, and other parameters before starting.
  • Executors.newVirtualThreadPerTaskExecutor(): Creates an ExecutorService that 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 Continuation object 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 synchronized block or method: Acquiring a synchronized monitor 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:

  1. Readiness signal: The JVM receives a notification that the resource has become available.
  2. Unparking: The JVM transitions the virtual thread from the PARKING state to the RUNNABLE state (ready to execute).
  3. Enqueuing: The JVM places the Continuation of this woken thread back into the ForkJoinPool queue for scheduling.
  4. Re-mounting: As soon as one of the pool's carrier threads becomes free, it "thaws" the Continuation and 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:

  1. The virtual thread transitions to the TERMINATED state.
  2. Its association with the Continuation object is broken.
  3. Garbage Collection: The thread object itself and its Continuation (the heap-allocated stack) no longer have strong references from GC Roots (e.g., from the ForkJoinPool). Therefore, during the next garbage collection cycle, they can be collected.
  4. 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 ThreadLocal with 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 via ThreadLocal also becomes meaningless and can lead to memory leaks. As a modern alternative for context propagation, you can use ScopedValue.
  • Track Pinning: On Java 21, use -Djdk.tracePinnedThreads to 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)