Virtual Threads (Project Loom)
Virtual Threads, delivered by Project Loom and finalized in Java 21, let you write plain, sequential-looking code that blocks freely — yet scales to handle hundreds of thousands (even millions) of concurrent tasks. You no longer have to choose between simple blocking code and high throughput.
The Problem Virtual Threads Solve
Traditional Java threads (now called platform threads) map 1-to-1 onto OS threads. OS threads are expensive: each one consumes around 1–2 MB of stack memory, and context-switching them involves costly kernel calls. A typical JVM can comfortably run only a few thousand at once.
This led to the reactive programming style — callbacks, CompletableFuture, RxJava, Project Reactor — where you avoid blocking at all costs. Reactive code is powerful but notoriously hard to read, debug, and reason about. Stack traces become meaningless and error handling turns into spaghetti.
Virtual Threads solve the root problem instead of working around it: they make blocking cheap.
Creating Virtual Threads
Virtual threads are instances of java.lang.Thread, so the API you already know works. You just create them differently.
// Option 1 — simplest: Thread.ofVirtual()
Thread vt = Thread.ofVirtual().name("my-virtual-thread").start(() -> {
System.out.println("Running in: " + Thread.currentThread());
});
vt.join();
Output:
Running in: VirtualThread[#21,my-virtual-thread]/runnable@ForkJoinPool-1-worker-1
// Option 2 — using a factory (great for naming many threads)
Thread.Builder.OfVirtual factory = Thread.ofVirtual().name("task-", 0);
Thread t1 = factory.start(() -> System.out.println("task-0 running"));
Thread t2 = factory.start(() -> System.out.println("task-1 running"));
t1.join();
t2.join();
// Option 3 — Executors.newVirtualThreadPerTaskExecutor()
// Drop-in replacement for a cached thread pool
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
final int taskId = i;
exec.submit(() -> {
// Simulate I/O with a plain blocking sleep
Thread.sleep(100);
System.out.println("Task " + taskId + " done");
return null;
});
}
} // auto-closes and awaits all tasks
Tip:
Executors.newVirtualThreadPerTaskExecutor()is the easiest migration path. Replace your existingExecutorServiceand keep the rest of your code untouched.
Blocking Is Fine — That’s the Whole Point
With platform threads, blocking a thread while waiting for I/O wastes a precious OS thread. With virtual threads, blocking is intentional and efficient.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.Executors;
import java.util.List;
List<String> urls = List.of(
"https://example.com",
"https://example.org"
);
try (var exec = Executors.newVirtualThreadPerTaskExecutor();
var client = HttpClient.newBuilder().executor(exec).build()) {
var futures = urls.stream()
.map(url -> exec.submit(() -> {
var req = HttpRequest.newBuilder(URI.create(url)).build();
// This blocking send() is perfectly fine inside a virtual thread
return client.send(req, HttpResponse.BodyHandlers.ofString()).statusCode();
}))
.toList();
for (var f : futures) {
System.out.println("Status: " + f.get());
}
}
The JVM automatically unmounts a virtual thread from its carrier when it blocks on I/O, a lock, or Thread.sleep(), freeing the carrier to run other virtual threads.
Note: CPU-bound work (heavy computation with no blocking) does NOT benefit from virtual threads. Virtual threads shine on I/O-bound workloads — database queries, HTTP calls, file reads.
Structured Concurrency (Preview)
Java 21 also previews Structured Concurrency (java.util.concurrent.StructuredTaskScope), which ensures that child threads live and die within the scope of their parent. This makes fan-out patterns far easier to manage correctly.
import java.util.concurrent.StructuredTaskScope;
// Java 21 preview — compile with --enable-preview
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var userTask = scope.fork(() -> fetchUser(1));
var orderTask = scope.fork(() -> fetchOrders(1));
scope.join(); // wait for both
scope.throwIfFailed(); // propagate any failure
var user = userTask.get();
var orders = orderTask.get();
System.out.println(user + " has " + orders.size() + " orders");
}
If either fetchUser or fetchOrders throws, the scope cancels the other and re-throws — no leaked threads, no manual Future bookkeeping.
Warning:
StructuredTaskScopeis a preview API in Java 21. Enable it with--enable-previewat compile and runtime. The API may change in future LTS releases.
Under the Hood
Understanding what happens inside the JVM helps you use virtual threads correctly.
Virtual Threads vs Platform Threads
| Feature | Platform Thread | Virtual Thread |
|---|---|---|
| Maps to | One OS thread | Scheduled on a ForkJoinPool |
| Stack size | ~1–2 MB (OS default) | Starts at a few hundred bytes, grows on heap |
| Max practical count | ~thousands | ~millions |
| Creation cost | High (system call) | Very low (heap allocation) |
| Blocking cost | High (OS thread sits idle) | Cheap (thread unmounted, carrier freed) |
| Thread-local variables | Supported | Supported (prefer ScopedValue for new code) |
| Synchronized blocks | Works, but pins the thread | Use ReentrantLock to avoid pinning (see below) |
Carriers and Mounting
The JVM runs virtual threads on top of a small pool of carrier threads (platform threads) managed by a ForkJoinPool. When a virtual thread needs to run, it is mounted onto a carrier. When it blocks (I/O, sleep, lock), it is unmounted — its stack is saved to the heap — and the carrier is free to run a different virtual thread. When the blocking operation completes, the virtual thread is rescheduled onto any available carrier.
This “park on heap, resume anywhere” design is what makes millions of virtual threads practical.
Thread Pinning — A Common Pitfall
A virtual thread is pinned to its carrier thread when:
- It enters a
synchronizedblock or method that blocks. - It calls native code (
JNI) that blocks.
When pinned, the carrier thread is held captive — behaving like a blocked platform thread. This can limit scalability.
// Pinning example — avoid in hot paths
synchronized (this) {
Thread.sleep(Duration.ofMillis(100)); // pins the carrier!
}
// Better — use ReentrantLock instead
import java.util.concurrent.locks.ReentrantLock;
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
Thread.sleep(Duration.ofMillis(100)); // virtual thread unmounts cleanly
} finally {
lock.unlock();
}
You can detect pinning by running your JVM with -Djdk.tracePinnedThreads=full.
Tip: Java 24+ eliminates most pinning caused by
synchronized(JEP 491). If you are on Java 21, preferReentrantLockin blocking sections for maximum throughput. See ReentrantLock & Monitors for details.
Thread-Locals vs ScopedValues
Thread pools and frameworks often use ThreadLocal to attach per-request state (user identity, transaction context, MDC). With a virtual thread per task, ThreadLocal still works, but it encourages patterns — like pooling virtual threads — that defeat the purpose of Project Loom.
ScopedValue (also previewed in Java 21) is the modern alternative: immutable, scoped to a call tree, and cheaply inherited by child threads.
// ScopedValue preview (Java 21+)
static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();
ScopedValue.where(CURRENT_USER, "alice").run(() -> {
System.out.println("User: " + CURRENT_USER.get()); // alice
// Any virtual thread forked here can also read CURRENT_USER
});
Practical Migration Tips
You can adopt virtual threads incrementally in an existing codebase:
- Replace your executor: swap
Executors.newFixedThreadPool(n)ornewCachedThreadPool()withExecutors.newVirtualThreadPerTaskExecutor(). - Stop tuning pool sizes: thread-count tuning was always a proxy for “how much blocking can I afford?” With virtual threads, blocking is cheap — remove those magic numbers.
- Avoid pooling virtual threads: pooling was necessary for platform threads to limit OS-thread usage. Pooling virtual threads defeats their purpose; create a new one per task.
- Watch for
synchronizedpinning: profile with-Djdk.tracePinnedThreads=fulland replace hotsynchronizedblocks withReentrantLock. - Frameworks: Spring Boot 3.2+, Quarkus, Micronaut, and Helidon all support virtual threads. In Spring Boot, set
spring.threads.virtual.enabled=true.
Checking If a Thread Is Virtual
Thread t = Thread.currentThread();
System.out.println("Is virtual? " + t.isVirtual());
Output:
Is virtual? true
Related Topics
- Multithreading — foundational concepts behind Java threads and the problems virtual threads address
- Thread Pools & Executors — how
ExecutorServiceworks and wherenewVirtualThreadPerTaskExecutor()fits in - ReentrantLock & Monitors — the locking strategy that avoids virtual thread pinning
- Callable & Future — submitting tasks and collecting results, which pairs naturally with virtual thread executors
- Java Memory Model — the visibility and ordering guarantees that still apply to virtual threads
- Java 21 LTS Features — the full set of features delivered alongside virtual threads in the Java 21 release