Virtual Threads (Java 21)
Virtual Threads (Java 21)
Virtual threads, finalized in Java 21 (JEP 444), are lightweight threads managed entirely by the JVM rather than mapped one-to-one onto an operating system thread. A platform thread costs roughly a megabyte of stack memory and real OS scheduling overhead, which is why a server built on one thread per request has traditionally been capped at a few thousand concurrent requests. A virtual thread costs only a few hundred bytes, and the JVM can run millions of them, temporarily mounting each one onto a small pool of ordinary platform threads called carriers.
What Is a Virtual Thread?
A virtual thread is a Thread object that the JVM schedules itself, rather than mapping directly onto an operating system thread the way a platform thread does. JEP 444's design goal was narrow and deliberate: let existing, ordinary sequential blocking code scale to a very large number of concurrent tasks, without asking developers to rewrite it in an asynchronous or callback-based style just to avoid exhausting a limited pool of expensive platform threads.
Why Virtual Threads Matter
Both examples below submit ten tasks and wait for every one of them to finish before printing a total, using ExecutorService.close() — a method added in Java 19 that blocks until all submitted work has completed, making both examples fully deterministic regardless of how the underlying threads are scheduled.
1// File: BeforeVirtualThreads.java
2import java.util.concurrent.*;
3import java.util.concurrent.atomic.*;
4
5public class BeforeVirtualThreads {
6 public static void main(String[] args) throws InterruptedException {
7 AtomicInteger completed = new AtomicInteger();
8
9 try (ExecutorService executor = Executors.newFixedThreadPool(4)) {
10 for (int i = 0; i < 10; i++) {
11 executor.submit(() -> {
12 completed.incrementAndGet();
13 });
14 }
15 }
16
17 System.out.println("Completed: " + completed.get());
18 }
19}Output:
Completed: 10
Executors.newFixedThreadPool(4) caps concurrency at four platform threads, queuing the rest of the work behind them. Swapping in a virtual thread per task removes that cap entirely — each task gets its own cheap thread instead of competing for a slot in a small pool.
1// File: AfterVirtualThreads.java
2import java.util.concurrent.*;
3import java.util.concurrent.atomic.*;
4
5public class AfterVirtualThreads {
6 public static void main(String[] args) throws InterruptedException {
7 AtomicInteger completed = new AtomicInteger();
8
9 try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
10 for (int i = 0; i < 10; i++) {
11 executor.submit(() -> {
12 completed.incrementAndGet();
13 });
14 }
15 }
16
17 System.out.println("Completed: " + completed.get());
18 }
19}Output:
Completed: 10
Both examples produce the same total, since ten tasks complete either way — the difference virtual threads make is architectural, not visible in this particular count: a real workload with thousands of concurrent, mostly-blocked tasks can run on a handful of carrier threads instead of needing thousands of expensive platform threads.
A team that gets this wrong does not see an obvious crash — they see a service that scales fine in a demo with ten concurrent users and then falls over at a few thousand, because every one of those users' requests is quietly waiting for a turn on a fixed-size platform-thread pool sized for a much smaller load.
Creating and Inspecting Virtual Threads
Thread.ofVirtual() and Thread.ofPlatform() both return a builder for creating a thread of that kind, and Thread.isVirtual() reports which kind a given thread actually is.
1// File: CreatingVirtualThreadsExample.java
2
3public class CreatingVirtualThreadsExample {
4 public static void main(String[] args) throws InterruptedException {
5 Thread vt = Thread.ofVirtual().unstarted(() -> {
6 System.out.println("Running on virtual thread: " + Thread.currentThread().isVirtual());
7 });
8 vt.start();
9 vt.join();
10
11 Thread platformThread = Thread.ofPlatform().unstarted(() -> {
12 System.out.println("Running on virtual thread: " + Thread.currentThread().isVirtual());
13 });
14 platformThread.start();
15 platformThread.join();
16 }
17}Output:
Running on virtual thread: true
Running on virtual thread: false
Thread.startVirtualThread(Runnable) is a shorthand for Thread.ofVirtual().start(runnable) when no further configuration of the thread is needed before starting it.
How Virtual Threads Work
A virtual thread is not itself a schedulable unit as far as the operating system is concerned — it is scheduled by the JVM onto a small, fixed-size pool of platform threads called carriers, typically sized to the number of available CPU cores. When a virtual thread performs a blocking operation the JVM understands, such as Thread.sleep(), blocking I/O, or waiting on a lock it does not already hold, it unmounts from its carrier, freeing that carrier to run a different virtual thread. Once the blocking operation completes, the virtual thread remounts onto some available carrier, not necessarily the same one it started on, and continues running.
One sentence before the diagram: many virtual threads share a small pool of carriers, taking turns mounting onto one only while they have actual work to do.
Thousands of virtual threads: A handful of carrier platform threads: vthread-1 (blocked on I/O) ---\ vthread-2 (running) ---+---> carrier-1 (running vthread-2) vthread-3 (blocked on I/O) ---/ vthread-4 (running) -------> carrier-2 (running vthread-4) vthread-5 (blocked on I/O) ---\ ...thousands more, most (blocked ones unmount and free their of them blocked at any carrier for another virtual thread given instant) to mount onto in the meantime)
This is what lets ordinary, sequential, blocking-looking code scale to a very large number of concurrent tasks without the memory and scheduling cost a platform thread per task would carry.
Common Use Cases
High-throughput servers handling many concurrent blocking calls — database queries, HTTP calls to other services, file I/O — are the primary use case virtual threads were designed for, letting a thread-per-request style scale far beyond what platform threads allowed.
Fanning out to several independent blocking operations at once, covered in full in this article's real-world example below, replaces a sequential loop of blocking calls with one virtual thread per call.
Simplifying code that previously used asynchronous chaining purely to avoid exhausting a limited thread pool — with virtual threads, ordinary sequential, blocking code can scale the way CompletableFuture chains previously had to.
Structured concurrency, a preview feature in Java 21, builds on virtual threads to treat a group of related subtasks as a single unit that succeeds or fails together, rather than as independently tracked threads.
Real-World Example
A fare comparison feature needs quotes from several airline partners, each modeled as a simulated blocking call, gathered concurrently instead of one after another.
1// File: FareQuote.java
2
3public record FareQuote(String airline, double price) {}1// File: FareAggregator.java
2import java.util.*;
3import java.util.concurrent.*;
4
5public class FareAggregator {
6
7 public List<FareQuote> fetchAllQuotes(List<String> airlines) throws InterruptedException {
8 List<FareQuote> quotes = Collections.synchronizedList(new ArrayList<>());
9
10 try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
11 for (String airline : airlines) {
12 executor.submit(() -> {
13 quotes.add(fetchQuote(airline));
14 });
15 }
16 }
17
18 quotes.sort(Comparator.comparing(FareQuote::airline));
19 return quotes;
20 }
21
22 private FareQuote fetchQuote(String airline) {
23 try {
24 Thread.sleep(50);
25 } catch (InterruptedException e) {
26 Thread.currentThread().interrupt();
27 }
28 double price = switch (airline) {
29 case "IndiGo" -> 4200.0;
30 case "Air India" -> 4800.0;
31 case "Vistara" -> 5100.0;
32 default -> 0.0;
33 };
34 return new FareQuote(airline, price);
35 }
36}1// File: FareAggregatorDemo.java
2import java.util.*;
3
4public class FareAggregatorDemo {
5 public static void main(String[] args) throws InterruptedException {
6 FareAggregator aggregator = new FareAggregator();
7
8 List<String> airlines = List.of("IndiGo", "Air India", "Vistara");
9 List<FareQuote> quotes = aggregator.fetchAllQuotes(airlines);
10
11 for (FareQuote quote : quotes) {
12 System.out.println(quote.airline() + ": Rs. " + quote.price());
13 }
14 }
15}Output:
Air India: Rs. 4800.0
IndiGo: Rs. 4200.0
Vistara: Rs. 5100.0
Thread.sleep(50) stands in for a real blocking network call — the important part is that each fetchQuote call runs on its own virtual thread, and executor.close() waits for every one of them to finish before fetchAllQuotes sorts and returns the results, keeping the final order deterministic regardless of how the individual calls were scheduled.
A mistake that appears often in fresher pull requests is fetching data from several downstream services one after another in a simple loop, then wondering why a page that calls five services takes roughly five times as long as calling one. Submitting each call as its own virtual-thread task, exactly as fetchAllQuotes does here, lets all of them run concurrently without needing to size a thread pool to match the number of downstream calls.
Combining Virtual Threads With Other Features
ExecutorService.close(), used throughout this article, is what makes try-with-resources a clean way to wait for a batch of virtual-thread tasks to finish. Records pair naturally with virtual-thread tasks as simple, immutable result types, exactly as FareQuote does here. Structured concurrency, still a preview feature in Java 21, is the more advanced next step for grouping related virtual-thread subtasks so that a failure in one can cancel the others automatically, rather than each task being tracked independently as this article's example does.
Best Practices
Create a new virtual thread per task instead of pooling them — they are cheap enough that pooling adds no benefit and works against the model virtual threads are designed around.
Reach for Executors.newVirtualThreadPerTaskExecutor() rather than manually looping over Thread.ofVirtual() calls, so task submission, waiting, and cleanup stay consistent with the rest of the ExecutorService API.
Avoid virtual threads for CPU-bound work with no blocking involved — a virtual thread still needs a carrier platform thread to actually execute on, so work that never unmounts gains nothing over an ordinary fixed-size platform thread pool sized to the number of CPU cores.
Prefer java.util.concurrent.locks.ReentrantLock over a synchronized block when a lock needs to be held across a blocking call on a virtual thread, since a synchronized block prevents the virtual thread from unmounting for its entire duration.
A pinned virtual thread does not just block itself — it ties up the underlying carrier thread too, which is exactly why pinning under load can quietly starve every other virtual thread waiting for a carrier to become free.
Common Mistakes
Holding a synchronized block across a blocking operation pins the virtual thread to its carrier for the block's entire duration — the thread cannot unmount even though it is blocked, which defeats the scalability virtual threads are meant to provide under load.
1// File: PinningExample.java
2
3public class PinningExample {
4
5 private static final Object LOCK = new Object();
6
7 static void slowOperation() {
8 synchronized (LOCK) {
9 // While inside this synchronized block, the virtual thread
10 // cannot unmount from its carrier thread even if this call
11 // blocks - it pins the carrier for the whole block's duration
12 try {
13 Thread.sleep(100);
14 } catch (InterruptedException e) {
15 Thread.currentThread().interrupt();
16 }
17 }
18 }
19}Assuming a virtual thread behaves like a platform thread for the purpose of keeping the JVM alive overlooks that virtual threads are always daemon threads, with no way to make one non-daemon.
1// File: DaemonMistake.java
2
3public class DaemonMistake {
4 public static void main(String[] args) throws InterruptedException {
5 Thread vt = Thread.ofVirtual().unstarted(() -> {
6 System.out.println("Started");
7 });
8
9 System.out.println("Is daemon: " + vt.isDaemon());
10 }
11}Output:
Is daemon: true
Because every virtual thread is a daemon thread, a main method that starts background virtual-thread work and returns without joining those threads can let the JVM exit before that work ever runs — unlike a non-daemon platform thread, which would keep the JVM alive until it finished.
Interview Questions
Q1. What is a virtual thread, and how does it differ from a platform thread?
A virtual thread is a lightweight thread managed by the JVM, not mapped one-to-one onto an OS thread — it is scheduled onto a small pool of platform threads called carriers and costs only a few hundred bytes, compared to roughly a megabyte of stack memory for a platform thread mapped directly onto an OS thread. The nuance interviewers listen for is whether you say "scheduled by the JVM," not just "lightweight" — the scheduling model is the actual mechanism, not just a marketing description.
Q2. Which Java version finalized virtual threads, and via which JEP?
Java 21 finalized virtual threads via JEP 444, after two rounds as a preview feature in Java 19 and Java 20.
Q3. What does it mean for a virtual thread to be "mounted" or "unmounted" from a carrier thread?
A virtual thread is mounted while it is actively running on a carrier platform thread. When it performs a blocking operation the JVM understands, it unmounts, freeing that carrier to run a different virtual thread, and remounts onto some available carrier — not necessarily the same one — once the blocking operation completes. This is the single most-tested concept in virtual thread interviews at product companies.
Q4. What is thread pinning, and what commonly causes it?
Pinning is when a virtual thread cannot unmount from its carrier even while blocked, holding that carrier hostage for the duration. The most common cause is a blocking operation performed inside a synchronized block, which prevents the virtual thread from unmounting for as long as the block is held. Interviewers listen for whether you can name ReentrantLock as the practical fix, not just identify the problem.
Q5. Are virtual threads daemon threads by default, and does this matter?
Yes, every virtual thread is always a daemon thread, with no way to make one non-daemon. It matters because a daemon thread does not keep the JVM alive on its own — background work started on virtual threads and never joined can be cut off if the JVM exits first.
Q6. Should virtual threads be pooled the way platform threads traditionally are?
No. Virtual threads are cheap enough that creating a new one per task is the intended usage — pooling them adds overhead for no benefit and works against the model, in contrast to platform threads, which are expensive enough that pooling and reusing them is standard practice.
Q7. Are virtual threads a good fit for CPU-bound work?
No, not specifically. Virtual threads help when a large number of tasks spend most of their time blocked on I/O or similar operations that allow unmounting — for pure CPU-bound work with no blocking, a virtual thread still needs a carrier to run on, so it gains nothing over an ordinary platform thread pool sized to the number of CPU cores. This is the question that separates candidates who understand the mechanism from those who have only memorized "virtual threads are faster."
FAQs
Do I need to change my code's programming style to use virtual threads?
No, that is the point of the design — ordinary sequential, blocking-looking code runs on a virtual thread exactly as it would on a platform thread, without needing to be rewritten in an asynchronous or callback-based style to scale.
What is Executors.newVirtualThreadPerTaskExecutor() for?
It returns an ExecutorService that starts a brand-new virtual thread for every submitted task, rather than pulling a thread from a fixed-size pool — the standard way to run a large number of concurrent tasks on virtual threads through the familiar ExecutorService API.
Does ExecutorService need to be closed manually when using try-with-resources?
No. ExecutorService gained a close() method in Java 19 that shuts it down and blocks until all submitted tasks finish, so using it in a try-with-resources block, as every example in this article does, closes it automatically.
Can a virtual thread be interrupted like a platform thread?
Yes, interruption works the same way — calling interrupt() on a virtual thread sets its interrupt status and wakes it from most blocking operations exactly as it would for a platform thread.
Is structured concurrency the same feature as virtual threads?
No, though the two are closely related. Virtual threads are the underlying lightweight thread mechanism; structured concurrency, still a preview feature in Java 21, is a higher-level API built on top of virtual threads for managing a group of related subtasks as one unit.
Do thread-local variables still work inside a virtual thread?
Yes, functionally they work the same way, but each thread-local value still consumes memory per thread, which adds up when running millions of virtual threads. ScopedValue, a preview feature in Java 21, offers a lighter-weight, read-only alternative designed with virtual threads' scale in mind.
How many virtual threads can a single JVM realistically run at once?
The design goal behind JEP 444 was to support millions of concurrently active virtual threads on ordinary hardware, in sharp contrast to platform threads, which are typically limited to a few thousand before memory and OS scheduling overhead become a bottleneck.
Summary
Virtual threads let the JVM run a very large number of lightweight threads on a small pool of carrier platform threads, unmounting a virtual thread whenever it blocks so its carrier can run other work in the meantime. This means ordinary, sequential, blocking-looking code — exactly the style this article's fare aggregator uses — can scale to a large number of concurrent I/O-bound tasks without the memory cost a platform thread per task would carry, and without rewriting the code in an asynchronous style.
The habit worth carrying forward from this article is creating a new virtual thread per task rather than pooling them, and watching for synchronized blocks around blocking calls, since pinning a virtual thread to its carrier is the single most common way to quietly lose the scalability virtual threads are meant to provide.
What to Read Next
Learn the basics of reading and writing files in Java.