Java 21 Virtual Threads Tutorial with Examples

Introduction A typical Java server handles each request on its own thread - simple to write, read and debug. But every Java thread used to be a platform thread: a thin wrapper around an operating system thread, which takes around a megabyte of memory and is expensive to create and switch. A server can only run a few thousand of them, and since most request-handling time is spent waiting - for a database, another service or a file - those threads sit idle, and the server runs out of threads long before it runs out of CPU. The usual workaround, asynchronous and reactive programming, scales well but makes code much harder to write and debug. Virtual threads, previewed in Java 19 and 20 and final in Java 21Click to read tutorial: Overview of New Features in Java 21 (JEP 444), solve this differently: they are lightweight threads managed by the JVM, so an application can have millions of them. This tutorial explains virtual threads with examples - creating them, running thousands of blocking tasks, how they work, pinning, and when to use them. Creating virtual threads A virtual thread is still a java.lang.Thread, so all the existing APIs work. Java 21 added builders to create them -
Three ways to start a virtual thread
//CreateVirtualThreads.java
public class CreateVirtualThreads {
  public static void main(String[] args) throws InterruptedException {
    Thread t1 = Thread.startVirtualThread(() -> System.out.println("Hello from a virtual thread"));

    Thread t2 = Thread.ofVirtual().name("worker-1").start(() ->
        System.out.println("Running in " + Thread.currentThread().getName()
            + ", isVirtual = " + Thread.currentThread().isVirtual()));

    Thread t3 = Thread.ofPlatform().name("platform-1").start(() ->
        System.out.println("Running in " + Thread.currentThread().getName()
            + ", isVirtual = " + Thread.currentThread().isVirtual()));

    t1.join(); t2.join(); t3.join();
    System.out.println("Virtual threads are daemon threads: " + t1.isDaemon());
  }
}
 OUTPUT of the above code
Hello from a virtual thread
Running in worker-1, isVirtual = true
Running in platform-1, isVirtual = false
Virtual threads are daemon threads: true
Thread.ofVirtual() and Thread.ofPlatform() return builders for the two kinds of thread, and isVirtual() tells them apart. Virtual threads are always daemon threads, so the JVM does not wait for them on exit - which is why the program joins them.
Ten thousand blocking tasks The real power of virtual threads shows with many tasks which mostly wait. The program below runs 10,000 tasks which each sleep for one second - standing in for a call to a database or a remote service - first on a pool of 100 platform threads, and then with one virtual thread per task -
Platform threads vs virtual threads for blocking tasks
//VirtualThreadsScale.java
import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;

public class VirtualThreadsScale {
  static void runTasks(String label, ExecutorService executor, int tasks) {
    long start = System.nanoTime();
    try (executor) {                                   // close() waits for all tasks (Java 19+)
      IntStream.range(0, tasks).forEach(i -> executor.submit(() -> {
        Thread.sleep(Duration.ofSeconds(1));          // a blocking call, e.g. a database query
        return i;
      }));
    }
    System.out.printf("%-34s %,6d tasks in about %d s%n", label, tasks, Math.round((System.nanoTime() - start) / 1e9));
  }

  public static void main(String[] args) {
    runTasks("Fixed pool of 100 platform threads", Executors.newFixedThreadPool(100), 1_000);
    runTasks("One virtual thread per task", Executors.newVirtualThreadPerTaskExecutor(), 10_000);
  }
}
 OUTPUT of the above code
Fixed pool of 100 platform threads  1,000 tasks in about 10 s
One virtual thread per task        10,000 tasks in about 1 s
Explanation of the code
  • With 100 platform threads, only 100 tasks can wait at a time, so even one tenth of the work - 1,000 tasks - takes 10 rounds of one second. 10,000 tasks would take 100 seconds.
  • Executors.newVirtualThreadPerTaskExecutor() starts a new virtual thread for every task. All 10,000 tasks sleep at the same time, so they finish in about one second, using only a few operating system threads.
  • Virtual threads are cheap enough that they should not be pooled: create a new one for every task, and use a semaphore if you need to limit how many tasks use a scarce resource at once.
  • The code inside the tasks is ordinary blocking code - no callbacks, no CompletableFuture chains. That is the point: virtual threads give the scalability of asynchronous code with the simplicity of synchronous code.
How virtual threads work The JVM runs virtual threads on a small pool of platform threads called carrier threads - by default a ForkJoinPool with one carrier per CPU core. When a virtual thread performs a blocking operation, such as reading from a socket, sleeping, or waiting on a lock from java.util.concurrent, the JVM unmounts it: its stack is saved to the heap, and the carrier thread is free to run another virtual thread. When the operation completes, the virtual thread is mounted again on a carrier, possibly a different one, and continues. Because a virtual thread's stack lives in the heap and grows as needed, it uses only a few hundred bytes to a few kilobytes of memory, instead of a megabyte.

Most of the JDK's blocking operations were rewritten to work this way - which is why the socket implementation was rewritten in Java 13Click to read: socket implementation was rewritten in Java 13, among other preparations.
Pinning and other caveats
  • Pinning: in Java 21, a virtual thread which blocks while inside a synchronized block or method, or in a native method, cannot be unmounted - it stays pinned to its carrier, blocking the carrier too. With few carriers, heavy pinning can limit scalability. The workaround in Java 21 was to use ReentrantLock instead of synchronized around blocking calls. Java 24 removed pinning for synchronizedClick to read: Java 24 removed pinning for synchronized.
  • CPU-bound work does not benefit: virtual threads help when tasks wait. For computations, the number of CPU cores is the limit, whatever kind of thread is used.
  • ThreadLocal: thread-local variables work, but with millions of threads, large per-thread caches in ThreadLocals can use a lot of memory. Scoped valuesClick to read: Scoped values, final in Java 25, are designed for this world.
  • Tools: thread dumps with jcmd <pid> Thread.dump_to_file -format=json include virtual threads, and JFR has events for them.
When to use virtual threads Use them for applications which handle many concurrent tasks that spend most of their time waiting - web servers, microservices calling other services, and programs making many database or HTTP calls. Frameworks have adopted them widely: Spring Boot enables them with one property, spring.threads.virtual.enabled=true, and servers such as Tomcat and Jetty can run each request on a virtual thread. Summary In this tutorial we saw how virtual threads, final in Java 21, are lightweight threads managed by the JVM. We created them with Thread.startVirtualThread() and Thread.ofVirtual(), and ran 10,000 blocking tasks in about a second with newVirtualThreadPerTaskExecutor(), where a pool of platform threads would take far longer. We went through how virtual threads are mounted on and unmounted from carrier threads, the caveats of pinning, CPU-bound work and thread-locals, and when to use them. Next, read about record patternsClick to read tutorial: Java 21 Record Patterns with Examples.