Understanding Java Virtual Threads with Example
Virtual Threads are one of the most important improvements to Java concurrency in recent years. They make it possible to run a very large number of concurrent tasks without creating the same number of expensive operating-system threads. Virtual threads are especially useful for applications that spend a significant amount of time waiting for I/O, such as REST APIs, database calls, file operations, and calls to external services. They allow developers to continue using familiar blocking Java code while achieving much higher concurrency.
1. Introduction
1.1 What Is a Virtual Thread?
A Virtual Thread is a lightweight thread managed primarily by the Java Virtual Machine (JVM) rather than directly by the operating system. From a developer’s perspective, a virtual thread behaves much like a normal Java Thread. It can execute blocking operations, sleep, wait for I/O, and use existing Java concurrency APIs. The important difference is how virtual threads are scheduled. Traditional Java threads, also called platform threads, normally correspond closely to operating-system threads. Virtual threads are instead scheduled by the JVM on a much smaller number of platform threads called carrier threads.
1.2 Why Is a Virtual Thread So Cheap?
Platform threads are relatively expensive because each thread requires operating-system resources and typically reserves a significant amount of memory for its stack. Virtual threads have much smaller initial memory requirements and are scheduled by the JVM. When a virtual thread performs a supported blocking operation, such as waiting for network I/O, the JVM can suspend that virtual thread and allow the underlying carrier thread to execute another virtual thread. Because of this architecture, an application can potentially create thousands or even millions of virtual threads, depending on the workload and available resources.
1.3 When Did Java Get Virtual Threads?
Virtual threads originated from Project Loom. They first appeared as a preview feature in Java 19 and continued as a preview feature in Java 20. Virtual threads became a standard, production-ready Java feature in Java 21 through JEP 444.
1.4 Platform Threads vs Virtual Threads
| Feature | Platform Thread | Virtual Thread |
|---|---|---|
| Managed primarily by | Operating system | JVM |
| Creation cost | Relatively high | Very low |
| Memory usage | Higher | Much lower |
| Suitable thread count | Hundreds or thousands | Potentially hundreds of thousands or more |
| Best suited for | CPU-intensive and traditional workloads | High-concurrency, I/O-bound workloads |
| Blocking operations | Occupy an OS thread while blocked | Can usually release the carrier thread while blocked |
1.5 Best Practices for Virtual Threads
- Use virtual threads mainly for I/O-bound work. They are particularly effective for network calls, database queries, file access, and similar blocking operations.
- Do not pool virtual threads. Virtual threads are designed to be created per task. Reusing a small pool defeats much of their purpose.
- Do not expect CPU-bound code to become faster. If 10,000 virtual threads perform CPU-intensive calculations, the number of available CPU cores is still the limiting factor.
- Limit access to scarce resources separately. For example, having 100,000 virtual threads does not mean a database can handle 100,000 simultaneous connections. Connection pools, semaphores, or other limits may still be necessary.
- Prefer simple blocking code where appropriate. One of the main advantages of virtual threads is that developers can write straightforward, sequential-looking code instead of building complex asynchronous callback chains.
1.6 When Should You Use Virtual Threads?
Virtual threads are a good choice when an application needs to handle many concurrent tasks that spend most of their time waiting for I/O. Typical examples include REST API requests, database operations, HTTP calls to external services, file processing, and messaging workloads. They are less beneficial for CPU-intensive tasks because virtual threads do not increase the number of available CPU cores or make computations execute faster.
1.7 Virtual Threads vs Asynchronous Programming
Virtual threads provide an alternative way to achieve high concurrency without requiring developers to rewrite application logic using asynchronous APIs or callback-based code. Technologies such as CompletableFuture and reactive programming can also handle concurrent I/O efficiently, but they often require applications to follow an asynchronous programming model. With virtual threads, developers can use familiar blocking APIs and write sequential-looking code while the JVM handles the efficient scheduling of virtual threads. Virtual threads do not replace asynchronous or reactive programming in every situation, but they can significantly simplify many thread-per-request server applications.
1.8 Why Virtual Threads Matter for Real Applications
Virtual threads are particularly valuable in server applications that need to handle a large number of concurrent requests. In a traditional thread-per-request model, each request may occupy a platform thread while waiting for a database query, HTTP response, file operation, or another external resource. When thousands of requests are active at the same time, maintaining a similar number of operating-system threads can consume significant memory and introduce scheduling overhead. Virtual threads make the thread-per-request model much more scalable. An application can create a virtual thread for each request or task, and when that virtual thread blocks on supported I/O, the JVM can suspend it and use the underlying carrier thread to execute other virtual threads. Once the I/O operation is ready to continue, the virtual thread can resume execution. This allows a relatively small number of platform threads to support a much larger number of concurrent virtual threads. This approach is useful for workloads such as REST APIs, microservices, database-driven applications, messaging systems, and services that make frequent calls to external APIs. Developers can continue writing familiar sequential blocking code instead of introducing asynchronous programming solely to increase thread scalability. This can make application code easier to write, debug, and maintain. However, virtual threads do not remove other system limits. Database connections, HTTP connection pools, memory, CPU capacity, and external service limits still need to be managed appropriately. Virtual threads mainly improve how efficiently an application handles large numbers of concurrent tasks that spend significant time waiting, rather than making CPU-intensive operations execute faster.
2. Java Virtual Threads Code Example
The following example simulates multiple service requests. Each request waits for one second, representing an I/O operation such as calling an API or querying a database.
// VirtualThreadExample.java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
public class VirtualThreadExample {
public static void main(String[] args) throws InterruptedException {
// 1. Start a single virtual thread
Thread virtualThread = Thread.startVirtualThread(() -> {
System.out.println(
"Single virtual thread: " + Thread.currentThread()
);
});
virtualThread.join();
// 2. Run 10,000 tasks using virtual threads
ThreadFactory factory = Thread.ofVirtual()
.name("virtual-task-", 1)
.factory();
long startTime = System.currentTimeMillis();
try (ExecutorService executor =
Executors.newThreadPerTaskExecutor(factory)) {
for (int i = 1; i <= 10_000; i++) {
int taskId = i;
executor.submit(() -> {
// Print only a few tasks to keep the output readable
if (taskId <= 5) {
System.out.println(
"Task " + taskId + " started on " + Thread.currentThread()
);
}
try {
// Simulates a blocking I/O operation
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
if (taskId <= 5) {
System.out.println(
"Task " + taskId + " completed on " + Thread.currentThread()
);
}
});
}
}
long duration = System.currentTimeMillis() - startTime;
System.out.println("All 10,000 tasks completed.");
System.out.println("Total time: " + duration + " ms");
}
}
2.1 Code Explanation
This example demonstrates both how to start an individual virtual thread and how virtual threads can support a large number of concurrent tasks. First, Thread.startVirtualThread() creates and immediately starts a single virtual thread, while Thread.currentThread() displays information about the thread executing the task. The join() method waits for this virtual thread to finish before the program continues. Next, Thread.ofVirtual().name("virtual-task-", 1).factory() creates a ThreadFactory that produces named virtual threads such as virtual-task-1 and virtual-task-2. This factory is passed to Executors.newThreadPerTaskExecutor(), which creates a new virtual thread for every submitted task. The for loop submits 10,000 tasks to demonstrate how virtual threads can efficiently support large numbers of concurrent operations. Each task uses Thread.sleep(1000) to simulate a one-second blocking I/O operation, during which the JVM can suspend the virtual thread and allow its carrier thread to perform other work. To keep the console output readable, only the first five tasks print their start and completion information. If a task is interrupted, the InterruptedException is handled by restoring the thread’s interrupt status with Thread.currentThread().interrupt(). The executor is managed using a try-with-resources block, which ensures that it is closed and all submitted tasks complete before execution continues. Finally, the program prints a confirmation that all 10,000 tasks have completed along with the approximate total execution time, demonstrating how virtual threads are well suited to highly concurrent, I/O-bound workloads.
2.2 Code Output
When the program runs, it first creates a single virtual thread using Thread.startVirtualThread() and prints information about that thread. After the single virtual thread completes, the executor creates a new named virtual thread for each of the 10,000 submitted tasks. All of these tasks can be handled concurrently without requiring 10,000 dedicated operating-system threads. Each task waits for one second to simulate a blocking I/O operation, but only the first five tasks print their start and completion information to keep the output readable. The thread names such as virtual-task-1 identify the virtual threads created by the custom ThreadFactory. Once all submitted tasks have finished, the program displays the total number of completed tasks and the approximate execution time.
Single virtual thread: VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1 Task 1 started on VirtualThread[#22,virtual-task-1]/runnable@ForkJoinPool-1-worker-1 Task 2 started on VirtualThread[#23,virtual-task-2]/runnable@ForkJoinPool-1-worker-2 Task 3 started on VirtualThread[#24,virtual-task-3]/runnable@ForkJoinPool-1-worker-3 Task 4 started on VirtualThread[#25,virtual-task-4]/runnable@ForkJoinPool-1-worker-4 Task 5 started on VirtualThread[#26,virtual-task-5]/runnable@ForkJoinPool-1-worker-5 Task 1 completed on VirtualThread[#22,virtual-task-1]/runnable@ForkJoinPool-1-worker-3 Task 3 completed on VirtualThread[#24,virtual-task-3]/runnable@ForkJoinPool-1-worker-1 Task 2 completed on VirtualThread[#23,virtual-task-2]/runnable@ForkJoinPool-1-worker-4 Task 5 completed on VirtualThread[#26,virtual-task-5]/runnable@ForkJoinPool-1-worker-2 Task 4 completed on VirtualThread[#25,virtual-task-4]/runnable@ForkJoinPool-1-worker-5 All 10,000 tasks completed. Total time: 1128 ms
The exact output, virtual-thread IDs, carrier-thread names, task order, and total execution time may vary between runs. A virtual thread may also resume on a different carrier thread after its blocking operation. Although the example creates 10,000 concurrent virtual-thread tasks, this does not mean that 10,000 tasks execute simultaneously on the CPU. Instead, the JVM efficiently schedules the virtual threads over a smaller number of carrier threads, making this model particularly useful for applications with large numbers of I/O-bound operations.
3. Conclusion
Java virtual threads provide a lightweight concurrency model for applications that need to execute large numbers of independent, mostly I/O-bound tasks. They became a standard Java feature in Java 21 and integrate with familiar APIs such as Thread, ExecutorService, and existing blocking Java libraries. Their biggest advantage is not making individual tasks execute faster, but allowing applications to handle far more concurrent tasks without requiring the same number of expensive operating-system threads. For modern Java server applications, particularly REST services, database-heavy systems, and applications calling external APIs, virtual threads can significantly simplify concurrency while providing excellent scalability.

