Prerequisite Reading:

This is an advanced article. If you’re new to Virtual Threads, we highly recommend starting with the introduction first: The Loom Revolution: Java’s 30-Year Journey to Easy Concurrency.


The simple elegance of the Virtual Threads API (Executors.newVirtualThreadPerTaskExecutor()) hides a marvel of JVM engineering. While developers can enjoy writing simple, blocking code, understanding the machinery underneath is crucial for troubleshooting, performance tuning, and appreciating the scale of the innovation.

This is a deep dive into the core components that make Project Loom work and how they change observability for developers and system administrators.

The Core Architecture: More Than Just a Thread

A Virtual Thread is not a simple wrapper around an OS thread. It’s a complex interplay of three key components:

  1. The Virtual Thread: A lightweight, heap-based Java object that represents a task. It has its own stack, but this stack lives on the Java heap, not in the operating system’s memory space. This is why you can have millions of them without exhausting system resources.

  2. The Continuation: This is the secret sauce. A Continuation is a special object that captures a “snapshot” of a computation’s state at a specific point—like a saved game. When a virtual thread is about to block, the JVM freezes its execution state into a Continuation object. When it’s ready to resume, the JVM “thaws” this object to continue exactly where it left off.

  3. The Carrier Thread: A regular, heavyweight Platform Thread. The JVM maintains a small pool of these carrier threads (by default, one for each CPU core). These are the only threads the operating system knows about. Their job is to be the “engine” that executes the virtual threads. A single carrier thread might execute hundreds of different virtual threads over its lifetime.

  4. The Scheduler: A component responsible for assigning (or “mounting”) a runnable Virtual Thread onto an available Carrier Thread. The default scheduler is a ForkJoinPool, which is highly optimized for this kind of work-stealing task management.

The Unmount/Mount Dance: A Step-by-Step Diagram

When a Virtual Thread encounters a blocking I/O operation that has been integrated with Loom (like SocketInputStream.read()), a carefully choreographed dance occurs.

sequenceDiagram participant V as "Virtual Thread" participant JVM participant C as "Carrier (Platform) Thread" participant N as "Network Driver (OS)" V->>JVM: "Calls socket.read()" JVM->>V: "Intercepts blocking call" Note over JVM: "This is a Loom-aware operation" JVM->>C: "unmounts Virtual Thread" Note over C: "Carrier Thread is now FREE!" C->>JVM: "Returns to scheduler pool" JVM->>N: "Registers non-blocking read with OS" Note right of N: "I/O operation is pending..." N-->>JVM: "I/O is ready (data received)" JVM->>V: "Marks Virtual Thread as runnable" JVM->>C: "mounts Virtual Thread on an available Carrier" C->>V: "Resumes execution after socket.read()"

This unmounting process is the key. The expensive OS thread is not blocked; it’s immediately returned to the pool to run other virtual threads while the first one waits for the network.

Troubleshooting and Observability: A New World

How do we see what’s going on? The tools have been updated to understand this new model.

For the System Administrator

A sysadmin using tools like top, htop, or ps -eLf on a Linux server will not see millions of threads. They will see a Java process with a small, relatively stable number of OS threads—the carrier threads.

  • What to Monitor: Instead of thread count, monitoring should focus on the JVM process’s overall CPU utilization and heap memory usage. A high CPU might indicate that tasks are CPU-bound, while a large heap could indicate that many virtual threads are being created and are holding onto objects.
  • The Takeaway: Loom makes the Java application a better citizen on the OS. The internal complexity is hidden from the operating system.

For the Java Developer

This is where the most significant changes have occurred. The primary tools for debugging Java applications have been adapted for Virtual Threads.

1. Thread Dumps

A traditional thread dump (jstack or kill -3) now has a new format. It clearly shows the relationship between Virtual Threads and their Carriers.

A thread dump will now contain entries like this:

"main" #1 [runnable]
  ...

"ForkJoinPool-1-worker-1" #27 [runnable] carrier_for:
  "virtual-thread-23" #55 [runnable]

"ForkJoinPool-1-worker-2" #28 [runnable] carrier_for:
  "virtual-thread-31" #87 [runnable]

... and so on

This output explicitly tells you that the platform thread "ForkJoinPool-1-worker-1" is currently acting as the carrier for the virtual thread "virtual-thread-23". This is invaluable for seeing what’s actively running.

2. Stack Traces and the Stack Machine

What happens to a stack trace when a thread is unmounted and later remounted on a different carrier thread?

  • The Virtual Thread Stack: As mentioned, a Virtual Thread’s stack lives on the Java heap. It’s stored as a linked list of stack “chunks.” When a Virtual Thread needs to run, the JVM copies the necessary parts of this heap-based stack onto the real OS stack of the carrier thread. When it unmounts, the state is saved back to the heap.

  • Stitched Stack Traces: The JVM performs magic to hide this complexity from you. When you get an exception, the JVM stitches together the stack trace to present a clean, logical view of the Virtual Thread’s execution path, regardless of how many different carrier threads it may have run on.

You get a normal-looking stack trace:

Exception in thread "virtual-thread-42" java.lang.RuntimeException: Something went wrong
    at com.example.MyService.processData(MyService.java:10)
    at com.example.MyApi.handleRequest(MyApi.java:25)
    // ...

The JVM has hidden the messy details of mounting and unmounting, preserving the simple mental model that is the core goal of Project Loom.

3. JDK Mission Control (JMC) and Flight Recorder (JFR)

This is the gold standard for deep-diving into Virtual Threads. JFR has a rich set of new events specifically for Loom:

  • jdk.VirtualThreadStart / jdk.VirtualThreadEnd: Track the lifecycle of virtual threads.
  • jdk.VirtualThreadPinned: A critical event that fires when a virtual thread cannot be unmounted (e.g., it’s inside a synchronized block or executing native code). Frequent pinning can degrade performance and is a key thing to look for.
  • jdk.VirtualThreadSubmitFailed: Fires if the scheduler rejects a new task.

By analyzing a JFR recording in JMC, you can visualize thread creation, pinpoint pinning issues, and get a true, detailed picture of your application’s concurrent behavior.

In conclusion, while Loom provides a beautifully simple API, it is backed by a sophisticated runtime that is fully observable through updated, powerful tools.