Core Java

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

FeaturePlatform ThreadVirtual Thread
Managed primarily byOperating systemJVM
Creation costRelatively highVery low
Memory usageHigherMuch lower
Suitable thread countHundreds or thousandsPotentially hundreds of thousands or more
Best suited forCPU-intensive and traditional workloadsHigh-concurrency, I/O-bound workloads
Blocking operationsOccupy an OS thread while blockedCan 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.

Yatin Batra

An experience full-stack engineer well versed with Core Java, Spring/Springboot, MVC, Security, AOP, Frontend (Angular & React), and cloud technologies (such as AWS, GCP, Jenkins, Docker, K8).
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button