Virtual Threads
For decades, Java concurrency has relied on a model where every java.lang.Thread instance corresponds directly to an operating system (OS) kernel thread. These are known as Platform Threads. While this 1:1 mapping is intuitive and easy to reason about, it creates a significant bottleneck for modern, high-scale applications. Because platform threads are heavy—each consuming several megabytes of memory for its stack and requiring expensive OS-level context switches—scaling to millions of concurrent connections becomes physically impossible due to resource exhaustion.
With the release of Java 21, the landscape of Java concurrency has fundamentally shifted with the introduction of Virtual Threads, the flagship feature of Project Loom. Virtual threads represent a paradigm shift, moving the responsibility of thread management from the operating system to the Java Virtual Machine (JVM).
What Makes Virtual Threads Different?
Unlike platform threads, virtual threads are lightweight, user-mode threads. They are designed to be extremely "cheap" to create and manage. The core advantages can be summarized in three key areas:
- Minimal Memory Footprint: While a platform thread might require a few megabytes of memory, a virtual thread typically requires only a few kilobytes. This allows the JVM to scale the memory usage dynamically based on the actual needs of the task.
- Massive Scalability: Because of their low overhead, a single machine can easily host millions of virtual threads. This is a quantum leap from the traditional limit of a few thousand platform threads.
- User-Mode Scheduling: Instead of relying on the OS kernel to manage scheduling, the JVM manages virtual threads by mapping them onto a small pool of platform threads, known as Carrier Threads.
It is vital to understand that virtual threads are not a "magic button" to make individual computations faster. They do not reduce the CPU time required for a single task. Instead, they drastically increase system throughput, particularly in applications that spend a significant amount of time waiting for I/O operations.
The Mechanics: Mounting and Unmounting
To understand how virtual threads achieve such high efficiency, we must look at the mechanism of M:N scheduling, where $M$ virtual threads are multiplexed onto $N$ carrier threads (where $N$ is typically equal to the number of available CPU cores).
The magic happens through a process of Mounting and Unmounting:
- Mounting: When a virtual thread is ready to execute, the JVM "mounts" it onto a carrier thread. The virtual thread then runs its logic just like a standard thread.
- The Blocking Event: In a traditional model, if a thread performs a blocking I/O operation (such as a database query or a network call), the underlying OS thread is also blocked, sitting idle and wasting resources. In the virtual thread model, when a virtual thread encounters a blocking operation, the JVM intercepts this.
- Unmounting: The JVM automatically unmounts the virtual thread from its carrier thread and saves its state (stack frames) in the heap.
- Carrier Thread Reusability: The carrier thread is now free to pick up and execute a different virtual thread immediately.
- Resuming: Once the I/O operation completes, the JVM schedules the original virtual thread to be mounted back onto an available carrier thread to continue exactly where it left off.
This seamless handoff ensures that the CPU is almost always doing productive work, rather than waiting for slow I/O devices.
Implementation and Usage
Java provides several streamlined ways to work with virtual threads, making them easy to integrate into existing codebases.
1. Using the Thread.ofVirtual() Builder
For simple, one-off tasks, you can use the static factory methods provided by the Thread class:
public class VirtualThreadDemo {
public static void main(String[] args) throws InterruptedException {
// Create an unstarted virtual thread
Thread vThread = Thread.ofVirtual().unstarted(() -> {
System.out.println("Running in a virtual thread: " + Thread.currentThread());
});
// Start the thread
vThread.start();
// Wait for completion
vThread.join();
}
}
If you want to create and launch the thread in a single step, you can use Thread.ofVirtual().start(...).
2. Using ExecutorService for High-Concurrency Tasks
In production environments, you rarely manage individual threads manually. Instead, you use an ExecutorService. For virtual threads, Java provides a specialized executor designed to create a new virtual thread for every single task submitted:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualThreadPoolDemo {
public static void main(String[] args) {
// Create an executor that spawns a new virtual thread per task
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
final int taskId = i;
executor.submit(() -> {
// Simulate an I/O-bound blocking operation
Thread.sleep(1000);
System.out.println("Task " + taskId + " completed");
return taskId;
});
}
} // The try-with-resources block automatically closes the executor and waits for tasks
}
}
In this example, running 10,000 concurrent tasks is trivial and will not exhaust the system's memory or thread limits.
Best Practices and Strategic Use Cases
To get the most out of virtual threads, developers must adapt their concurrency strategies.
When to Use Virtual Threads
- I/O-Bound Applications: This is the "sweet spot." Web servers (like Spring Boot or Helidon), microservices interacting with databases, and API clients that wait for network responses will see massive throughput improvements.
- Simplifying Asynchronous Code: Virtual threads allow you to write code in a simple, synchronous, "blocking" style while achieving the performance of complex, non-blocking reactive programming.
When to Avoid Virtual Threads
- CPU-Bound Applications: If your workload consists of heavy mathematical computations, image processing, or encryption, virtual threads will provide no benefit. Since these tasks require constant CPU cycles, you are still limited by the number of physical cores. In these cases, traditional platform threads or specialized parallel processing frameworks are more appropriate.
Critical Rules to Remember
- Do Not Pool Virtual Threads: In the past, we used thread pools (like
ThreadPoolExecutor) to limit the number of platform threads and avoid overhead. Because virtual threads are so lightweight, pooling them is an anti-pattern. Instead, always create a new virtual thread for every task. - Beware of "Pinning" with
synchronized: When a virtual thread enters asynchronizedblock or method, the JVM may "pin" the virtual thread to its carrier thread. This prevents the virtual thread from being unmounted during I/O, effectively turning it back into a heavy platform thread. To avoid this, prefer usingjava.util.concurrent.locks.ReentrantLock, which allows the JVM to unmount the thread properly.
Virtual threads represent a massive leap forward for the Java ecosystem, enabling developers to build highly scalable, high-throughput systems without the cognitive overhead of complex asynchronous frameworks.