Virtual threads (JEP 444, final in Java 21) are lightweight threads managed by the JVM — you can have millions, so plain blocking code scales like async code.
Platform vs virtual threads
| Platform thread | Virtual thread | |
|---|---|---|
| Backed by | One OS thread for its whole life | Any carrier thread, only while running |
| Stack | Fixed, ~1 MB reserved | Heap-allocated, grows as needed (KBs) |
| How many | Thousands | Millions |
| Creation | Expensive — pool them | Cheap — create one per task, never pool |
| Scheduler | OS | JVM (ForkJoinPool of carriers, ~one per core) |
| Always daemon? | Configurable | Yes; priority fixed |
| Best for | CPU-bound work, long-lived threads | High-concurrency blocking I/O (servers, clients) |
Creating virtual threads
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread v = Thread.ofVirtual().name("v-worker").start(() ->
System.out.println(Thread.currentThread().getName()
+ " virtual=" + Thread.currentThread().isVirtual()
+ " daemon=" + Thread.currentThread().isDaemon()));
v.join();
Thread quick = Thread.startVirtualThread(() -> System.out.println("started with startVirtualThread"));
quick.join();
Thread unstarted = Thread.ofVirtual().unstarted(() -> { });
System.out.println("state = " + unstarted.getState());
Thread p = Thread.ofPlatform().name("p-worker").start(() ->
System.out.println(Thread.currentThread().getName() + " virtual=" + Thread.currentThread().isVirtual()));
p.join();
}
}v-worker virtual=true daemon=true
started with startVirtualThread
state = NEW
p-worker virtual=falseOne virtual thread per task
10 000 tasks that each block for 10 ms. A pool of 100 platform threads would need ~1 s; with a thread per task the sleeps all overlap and the whole batch finishes in roughly the time of one.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class Main {
public static void main(String[] args) throws Exception {
List<Future<Integer>> results = new ArrayList<>();
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int n = i;
results.add(exec.submit(() -> {
Thread.sleep(10); // blocking call: the virtual thread unmounts
return n;
}));
}
} // close() waits for all 10 000
long sum = 0;
for (Future<Integer> f : results) sum += f.resultNow();
System.out.println(results.size() + " tasks, sum = " + sum);
}
}10000 tasks, sum = 49995000When they help — and when they don't
Pitfalls
| Pitfall | Why | Instead |
|---|---|---|
| Pooling virtual threads | They are cheap; pooling just limits concurrency | One per task; limit access to a scarce resource with a Semaphore |
| Pinning (Java 21) | Blocking inside synchronized (or a native call) keeps the carrier occupied | Use ReentrantLock around blocking I/O; diagnose with -Djdk.tracePinnedThreads=full (fixed for synchronized in JDK 24, JEP 491) |
Heavy ThreadLocal caches | A million threads × a cached object each = memory blow-up | Pass context explicitly; scoped values (preview) |
| Long CPU loops | No time-slicing preemption between virtual threads | Keep CPU-heavy work on a platform pool |
Do virtual threads make code faster?
What is a carrier thread? What is pinning?
Should you use a thread pool of virtual threads?