Virtual Threads in Java

Naveen AnamaneniUpdated · 5 min read

Virtual threads, added as a final feature in Java 21, are lightweight threads managed by the JVM rather than by the operating system. A normal (platform) thread is a wrapper around an expensive operating-system thread, so a program can only afford a few thousand of them. A virtual thread costs only a small amount of memory, so a program can create hundreds of thousands or millions. They are designed for code that spends most of its time waiting, such as web servers calling databases and other services. In this lesson, you will learn how to create virtual threads and how they work.

How Virtual Threads Work

Many virtual threads share a few carrier threads; waiting threads step aside
  • Virtual threads run on a small pool of platform threads called carrier threads, by default one per CPU core.
  • When a virtual thread blocks (sleep, a socket read, a lock wait), the JVM unmounts it from its carrier and stores its stack in the heap. The carrier immediately runs another virtual thread.
  • When the blocking operation finishes, the virtual thread is mounted again on any free carrier and continues.
  • Your code stays plain and blocking: no callbacks or CompletableFuture chains are needed.

Ways to Create Virtual Threads

WayCode
Start one directlyThread.startVirtualThread(task)
Thread builderThread.ofVirtual().name("worker").start(task)
One per task in an executorExecutors.newVirtualThreadPerTaskExecutor()
A factory for other APIsThread.ofVirtual().factory()

Example: Creating Virtual Threads

Let's see an example of the three common ways.

VirtualBasics.java▶ Try it
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
 
public class VirtualBasics {
    public static void main(String[] args) throws Exception {
        Thread t1 = Thread.startVirtualThread(() ->
            System.out.println("Hello from a virtual thread"));
        t1.join();
        System.out.println("t1 is virtual? " + t1.isVirtual());
 
        Thread t2 = Thread.ofVirtual().name("navika-task").start(() ->
            System.out.println("Running " + Thread.currentThread().getName()));
        t2.join();
 
        try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
            Future<String> f = ex.submit(() -> {
                Thread.sleep(100);
                return "virtual? " + Thread.currentThread().isVirtual();
            });
            System.out.println("Executor task says " + f.get());
        }
        System.out.println("main is virtual? "
            + Thread.currentThread().isVirtual());
    }
}
Output
Hello from a virtual thread
t1 is virtual? true
Running navika-task
Executor task says virtual? true
main is virtual? false

Explanation: startVirtualThread creates and starts a virtual thread in one call. The builder Thread.ofVirtual() lets you name it. newVirtualThreadPerTaskExecutor() creates a new virtual thread for every submitted task, so existing executor code switches to virtual threads with a one-line change. The main thread itself is a platform thread.

Example: 100,000 Sleeping Threads

ManyThreads.java▶ Try it
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
 
public class ManyThreads {
    public static void main(String[] args) {
        AtomicInteger done = new AtomicInteger();
        long start = System.nanoTime();
        try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 100_000; i++) {
                ex.submit(() -> {
                    Thread.sleep(1000);
                    return done.incrementAndGet();
                });
            }
        }
        long ms = (System.nanoTime() - start) / 1_000_000;
        System.out.println("Finished tasks: " + done.get());
        System.out.println("All in about one second (under 3 s)? "
            + (ms < 3000));
    }
}
Output
Finished tasks: 100000
All in about one second (under 3 s)? true

Explanation: 100,000 tasks each sleep for one second. With virtual threads, they all sleep at the same time on a handful of carrier threads, so the whole job takes a little over one second. Creating 100,000 platform threads would use gigabytes of stack memory or fail outright.

Common Mistakes

Pooling virtual threads. They're cheap; create a new one per task (see "Virtual Thread Best Practices").

Expecting speed-ups for CPU-bound code. The number of cores is still the limit.

Assuming Thread.currentThread() is a platform thread. In a virtual thread it returns the virtual thread.

Interview Corner

What are virtual threads?

Virtual threads, final in Java 21 (JEP 444), are Thread instances implemented by the JVM rather than mapped one-to-one onto operating-system threads. They're scheduled onto a small pool of carrier platform threads; when a virtual thread blocks on I/O, sleep or a lock, it's unmounted and its stack saved in the heap, freeing the carrier. They're cheap enough to create one per task, which allows simple blocking code to scale to very high concurrency for I/O-bound workloads.

Practice

  1. Easy: Start ten virtual threads named vt-1 to vt-10 with Thread.ofVirtual().name("vt-", 1), and print each name.
  2. Harder: Change ManyThreads to use Executors.newFixedThreadPool(200) and predict, then measure, how long it takes.

Quick check: Virtual threads

4 questions
1In which version did virtual threads become final?
2What happens to a virtual thread when it blocks on I/O?
3Which executor creates one virtual thread per task?
4What kind of work benefits most from virtual threads?

Key Takeaways

Was this tutorial helpful?

Lesson 428 of 482