Introduction
Virtual threads are lightweight, JVM-managed threads introduced in Java 21 through Project Loom. They allow you to create millions of threads on commodity hardware without exhausting system resources, because the operating system remains unaware of their existence. Each virtual thread is mounted on a small pool of carrier threads that are ordinary Java platform threads. When a virtual thread blocks on I/O, the JVM detaches it from the carrier so another virtual thread can run, giving you near-optimal hardware utilization without writing reactive or callback-based code.
Traditional Platform Thread Example
The classic approach uses Thread or an ExecutorService backed by platform threads. The snippet below launches 10000 tasks that each sleep for one second. On most laptops the program either crashes with an out-of-memory error or takes a long time to start, because each platform thread consumes roughly one megabyte of stack space.
public class PlatformThreadDemo {
public static void main(String[] args) throws InterruptedException {
try (var executor = Executors.newFixedThreadPool(10_000)) {
for (int i = 0; i < 10_000; i++) {
final int id = i;
executor.submit(() -> {
try {
Thread.sleep(Duration.ofSeconds(1));
} catch (InterruptedException ignored) {
}
return id;
});
}
}
}
}
Virtual Thread Example
With virtual threads the same workload runs comfortably in a few megabytes of heap. The only change is the factory method used to create the executor.
public class VirtualThreadDemo {
public static void main(String[] args) throws InterruptedException {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
final int id = i;
executor.submit(() -> {
try {
Thread.sleep(Duration.ofSeconds(1));
} catch (InterruptedException ignored) {
}
return id;
});
}
}
}
}
Running this version typically finishes in a little over one second on an eight-core machine, demonstrating that virtual threads scale to task count rather than core count.
Structured Concurrency with Virtual Threads
Virtual threads shine when paired with structured concurrency, introduced in Java 21 as an incubating API. The following example fetches a user profile and order history concurrently while guaranteeing that both subtasks complete or fail together.
public class StructuredConcurrencyDemo {
record User(String name) {}
record Order(long id) {}
public static void main(String[] args) throws InterruptedException, ExecutionException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Supplier<User> user = scope.fork(() -> fetchUser());
Supplier<Order> order = scope.fork(() -> fetchOrder());
scope.join().throwIfFailed();
System.out.println(STR."User: \{user.get()}, Order: \{order.get()}");
}
}
private static User fetchUser() throws InterruptedException {
Thread.sleep(Duration.ofMillis(200));
return new User("Alice");
}
private static Order fetchOrder() throws InterruptedException {
Thread.sleep(Duration.ofMillis(300));
return new Order(42);
}
}
The StructuredTaskScope automatically cancels remaining subtasks if any fail, eliminating the common leaks and race conditions found in raw Future code.
When Not to Use Virtual Threads
Virtual threads are not a silver bullet. They are ideal for workloads that spend most of their time blocked on I/O, but they provide no advantage for CPU-bound computations because only one virtual thread runs on a carrier thread at any instant. If your task saturates the CPU, stick to the existing fork-join pool or reactive streams. Additionally, avoid pooling virtual threads; doing so negates their scalability benefits and creates contention on the pool’s internal locks.
Conclusion
Virtual threads remove the historical trade-off between simple, synchronous code and high scalability. By letting the JVM manage millions of lightweight threads, you can write straightforward blocking logic and still utilize hardware efficiently. Adopt virtual threads for I/O-heavy workloads, combine them with structured concurrency to prevent leaks, and continue using platform threads or specialized frameworks when computation dominates. The result is clearer, safer code that scales from laptops to cloud deployments without rewriting your mental model of Java concurrency.
