Virtual Threads
Virtual threads, finalized in Java 21 (JEP 444, after preview in Java 19–20) as part of Project Loom, are lightweight threads implemented and scheduled by the JVM itself rather than mapped one-to-one onto a limited number of operating-system threads, allowing applications to create and run enormous numbers of concurrent threads — potentially millions — without the memory and context-switching overhead that has always made traditional, OS-backed (platform) threads an expensive, carefully-rationed resource. This entry covers exactly why platform threads were a scarce resource and how that scarcity shaped Java's concurrency idioms for decades, how virtual threads are created and how mounting/unmounting onto carrier platform threads works, the concept of pinning and which operations prevent a virtual thread from unmounting, and practical guidance on when virtual threads help versus when they don't.
Motivation — Platform Threads Were Always a Scarce, Expensive Resource
// ── The old, scarcity-driven pattern — bounded thread pool + async style ─
ExecutorService pool = Executors.newFixedThreadPool(200); // a CAREFULLY SIZED, limited pool —
// creating thousands of platform threads directly would exhaust OS thread resources
CompletableFuture<String> result = CompletableFuture
.supplyAsync(() -> callDatabase(), pool)
.thenApplyAsync(dbResult -> callDownstreamService(dbResult), pool)
.thenApply(serviceResult -> processResult(serviceResult));
// Sequential LOGIC fragmented across separate lambda stages — error handling,
// stack traces, and natural control flow all become harder to follow
// ── Java 21+ — virtual threads make simple, sequential, BLOCKING code viable
// at massive concurrency, with no thread-pool sizing concerns at all:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) { // 100,000 concurrent tasks — completely
executor.submit(() -> { // impractical with platform threads,
String dbResult = callDatabase(); // routine with virtual threads
String serviceResult = callDownstreamService(dbResult);
return processResult(serviceResult);
// Straightforward, sequential, BLOCKING code — no callback chain,
// no CompletableFuture composition, natural stack traces on exception
});
}
}Creating Virtual Threads and How Mounting Works
// ── Creating virtual threads directly ───────────────────────────────────
Thread vt = Thread.ofVirtual().start(() -> {
System.out.println("Running on: " + Thread.currentThread()); // isVirtual() == true
});
vt.join();
// ── The idiomatic pattern — one virtual thread PER submitted task ──────
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
int taskId = i;
futures.add(executor.submit(() -> {
Thread.sleep(Duration.ofMillis(100)); // ordinary BLOCKING call — virtual thread
return "Task " + taskId + " done"; // UNMOUNTS from its carrier during the sleep,
})); // freeing that carrier for other virtual threads
}
for (Future<String> f : futures) {
System.out.println(f.get());
}
}
// 10,000 concurrent virtual threads, each blocking for 100ms, complete in roughly
// 100ms total wall-clock time — NOT 10,000 x 100ms — because the small pool of
// carrier platform threads is shared efficiently via mount/unmount, not exhausted
// ── Existing concurrency APIs work unmodified on virtual threads ───────
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
Thread.ofVirtual().start(() -> {
try {
String item = queue.take(); // ordinary blocking call — unmounts the carrier while waiting
process(item);
} catch (InterruptedException e) { /* ... */ }
});Pinning, Carrier-Thread Limitations, and When Virtual Threads Help
// ── PITFALL: synchronized PINS a virtual thread to its carrier ─────────
Object lock = new Object();
Thread.ofVirtual().start(() -> {
synchronized (lock) { // virtual thread is PINNED here —
slowBlockingOperation(); // cannot unmount during this entire block,
} // carrier thread is unavailable to others meanwhile
});
// ── Mitigation — prefer ReentrantLock over synchronized for code on virtual threads
ReentrantLock reentrantLock = new ReentrantLock();
Thread.ofVirtual().start(() -> {
reentrantLock.lock();
try {
slowBlockingOperation(); // does NOT pin the carrier — virtual thread can
} finally { // unmount normally even while "holding" this lock
reentrantLock.unlock();
}
});
// ── Virtual threads help I/O-bound concurrency; they do NOT help CPU-bound work
// GOOD fit — many concurrent tasks, mostly waiting on I/O:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (String url : thousandsOfUrls) {
executor.submit(() -> fetchUrl(url)); // mostly blocked waiting on network I/O —
} // virtual threads scale this very well
}
// POOR fit — sustained CPU computation, no blocking at all:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> computeExpensiveHash(largeData)); // pure CPU work —
// still fundamentally limited by actual CPU CORE count; virtual threads add
// creation/scheduling overhead here with NONE of their intended benefit
}
}
// For CPU-bound work, a bounded pool sized close to availableProcessors()
// (e.g. a regular fixed thread pool) remains the appropriate tool, not virtual threads