Introduced as part of Project Loom and standardized in Java 21, virtual threads enable applications to create millions of concurrent tasks while using only a relatively small number of operating system threads.
Before Project Loom, Java concurrency relied almost entirely on platform threads. Every Java thread corresponded to a dedicated operating system thread using a 1:1 mapping.
Creating an operating system thread is relatively expensive because it requires native memory, kernel resources, and context-switching support from the operating system.
As the number of concurrent requests increases, applications quickly encounter limitations such as:
- High memory consumption.
- Expensive context switching.
- Large thread pools.
- Reduced scalability for I/O-bound applications.
For example, a web server handling 100,000 simultaneous requests may require thousands of platform threads, even though many of them spend most of their time waiting for database queries or network responses.

What are Virtual Threads?
A virtual thread behaves like a normal Java thread from the developer's perspective, but its lifecycle is managed entirely by the JVM.Instead of permanently occupying an operating system thread, a virtual thread is scheduled onto a carrier thread.
When the virtual thread blocks during supported operations such as network or file I/O, the JVM temporarily removes it from the carrier thread, allowing the carrier thread to execute another virtual thread.
This enables applications to create hundreds of thousands or even millions of virtual threads without requiring millions of operating system threads.
Platform threads are generally best suited for CPU-bound tasks, while virtual threads are designed for highly concurrent I/O-bound tasks such as network, database, and file operations.
Virtual threads do not make CPU-intensive computations faster. When the workload is limited by processor cores rather than blocking I/O, virtual threads provide little or no performance advantage over platform threads.
How Virtual Threads Work?
Unlike platform threads, virtual threads are not permanently attached to operating system threads.Instead, the JVM maintains a scheduler that maps many virtual threads onto a relatively small number of carrier threads.
These carrier threads are ordinary platform threads managed by the operating system.

If the virtual thread performs a blocking operation such as reading from a socket, the JVM suspends the virtual thread, detaches it from the carrier thread, and stores its execution state.
The carrier thread immediately becomes available to execute another virtual thread.
Once the blocking operation completes, the scheduler mounts the virtual thread onto any available carrier thread and resumes execution from where it previously stopped.
Carrier Threads
A carrier thread is a regular platform thread that executes one or more virtual threads. Virtual threads never execute directly on the CPU; instead, the JVM scheduler temporarily assigns each virtual thread to a carrier thread.
Unlike traditional platform threads, carrier threads are shared among many virtual threads. As virtual threads block and resume, the scheduler continuously mounts and unmounts them from available carrier threads.
Creating Virtual Threads
Creating a virtual thread is almost identical to creating a platform thread. The simplest approach is using Thread.startVirtualThread().Thread thread = Thread.startVirtualThread(() -> {
System.out.println("Running...");
});
thread.join();
Virtual threads can also be created using a thread builder.
Thread thread = Thread.ofVirtual()
.start(() -> {
System.out.println("Hello");
});
From the application's perspective, virtual threads behave like ordinary Java threads. They support interruption, exception handling, synchronization, and the familiar Thread API.
Executors with Virtual Threads
Virtual threads integrate seamlessly with the ExecutorService framework. Java provides a dedicated executor that creates a new virtual thread for every submitted task.try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> System.out.println("Task 1"));
executor.submit(() -> System.out.println("Task 2"));
}
Unlike traditional thread pools, this executor does not reuse virtual threads.
Instead, each submitted task receives its own virtual thread, while the JVM efficiently schedules all virtual threads onto a small number of carrier threads.
This greatly simplifies concurrent programming because developers no longer need to spend significant effort tuning thread pool sizes for I/O-bound workloads.
ThreadLocal and Scoped Values
Virtual threads fully support ThreadLocal, allowing each virtual thread to maintain its own thread-specific data.ThreadLocal currentUser = new ThreadLocal<>();
Thread.startVirtualThread(() -> {
currentUser.set("Alice");
System.out.println(currentUser.get());
currentUser.remove();
});
Each virtual thread has its own independent ThreadLocal values.
However, creating millions of virtual threads may also create millions of ThreadLocal instances, increasing memory usage if they are not cleaned up properly.
To address this, Project Loom introduces Scoped Values (preview feature), which provide an immutable and more efficient mechanism for sharing contextual data during the lifetime of a task.
static final ScopedValue USER =
ScopedValue.newInstance();
ScopedValue.runWhere(USER, "Alice", () -> {
System.out.println(USER.get());
});
Unlike ThreadLocal, scoped values are immutable within their scope and are automatically discarded when execution leaves that scope.
They are well suited for propagating read-only contextual information such as user identities, request IDs, locale information, or security context.
In general, use ThreadLocal only when mutable thread-specific state is required.
For read-only contextual data in highly concurrent virtual-thread applications, Scoped Values are the preferred approach because they are simpler, safer, and more memory efficient.
Pinning
A virtual thread achieves its scalability by releasing its carrier thread whenever it blocks on supported operations. However, there are situations where the virtual thread cannot be unmounted.This situation is known as pinning.
When a virtual thread is pinned, it continues to occupy its carrier thread while blocked, preventing the carrier thread from executing other virtual threads.
Excessive pinning reduces the scalability benefits of virtual threads.

private final Object lock = new Object();
void process() {
synchronized (lock) {
Thread.sleep(5000);
}
}
During the synchronized block, the virtual thread remains attached to its carrier thread, preventing the carrier thread from being reused until execution continues.
Whenever possible, avoid performing long-running or blocking operations while holding intrinsic locks.
In highly concurrent applications, ReentrantLock is often a better choice because it allows the JVM to manage virtual threads more efficiently.
Avoid long-running synchronized blocks and unnecessary ThreadLocal usage, as both can reduce the scalability benefits of virtual threads. Prefer designing tasks to be independent and short-lived, allowing the JVM scheduler to efficiently utilize carrier threads.
Structured Concurrency (Overview)
Traditional concurrent programming often involves manually creating threads, waiting for their completion, and handling failures across multiple tasks.As applications grow, coordinating related tasks becomes increasingly complex.
Structured Concurrency is a Project Loom feature that treats a group of concurrent tasks as a single unit of work.
The parent task creates child tasks, waits for their completion, and manages their lifecycle collectively.
For example, a web request may need to fetch user information, account details, and recent orders concurrently.
Instead of manually coordinating multiple threads, structured concurrency allows these related tasks to execute together while simplifying cancellation, exception handling, and resource management.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> userService.getUser());
var account = scope.fork(() -> accountService.getAccount());
var orders = scope.fork(() -> orderService.getOrders());
scope.join();
scope.throwIfFailed();
System.out.println(user.get());
System.out.println(account.get());
System.out.println(orders.get());
}
Although still a preview feature, structured concurrency encourages a more maintainable approach to concurrent programming by ensuring that child tasks cannot outlive the scope in which they were created.
Final Notes
Project Loom fundamentally changes Java's concurrency model by introducing lightweight virtual threads that enable applications to handle millions of concurrent tasks without requiring millions of operating system threads.By scheduling virtual threads onto a small pool of carrier threads and automatically mounting and unmounting them during blocking operations, the JVM dramatically improves scalability while preserving the familiar Java thread programming model.
Virtual threads excel for I/O-bound applications, while CPU-intensive workloads continue to benefit from traditional parallel execution techniques.
With virtual threads becoming a standard feature in Java 21, they represent one of the most significant advancements in Java concurrency since the introduction of the java.util.concurrent package.