Definition
- Virtual threads – lightweight threads managed by the JVM, not the OS; each uses ~1KB stack instead of ~1MB for platform threads.
- Carrier threads – a small pool of platform threads that virtual threads are mounted on; when a virtual thread blocks on IO, the JVM unmounts it and the carrier serves another virtual thread.
- Replaces – custom thread pool sizing for IO,
@Asyncannotations, simplesupplyAsync()wrapping of blocking calls. - Does not replace CF – thenCombine, allOf, anyOf, orTimeout, exceptionally for composing parallel calls with timeouts and fallbacks.
- Does not replace platform pools – CPU-bound parallelism needs all cores busy; virtual threads do not speed up computation.
- Coexistence – virtual threads for IO fan-out, CF for composition, platform pools for CPU-bound work.
At National Bank of India: platform threads are full-time permanent tellers (expensive, limited to ~200). Virtual threads are freelance tellers on call (millions available, nearly free). When a freelance teller waits for the vault to open (IO block), they step aside and another freelance uses the counter (carrier thread). But for counting cash (CPU work), you still need permanent tellers at dedicated counters. And for "fetch balance and credit score simultaneously, combine results," you still need CompletableFuture's thenCombine.
Understanding the Concepts
What are virtual threads?
Virtual threads (Java 21, Project Loom) are lightweight threads managed by the JVM. Unlike platform threads (one-to-one with OS threads), virtual threads are multiplexed onto a small pool of carrier platform threads.
// Creating virtual threads (Java 21)
Thread vt = Thread.ofVirtual().start(() -> {
String result = httpGet("https://api.bank.com/balance");
// Blocks, but JVM unmounts this virtual thread
// Carrier thread freed for another virtual thread
});
// Executor with virtual threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
jdbcQuery("SELECT balance FROM accounts");
// 100K virtual threads, each blocking on JDBC
// All on ~4 carrier threads!
});
}
}
What do virtual threads replace?
Before virtual threads, IO-bound concurrency required async code or thread pool tuning.
// BEFORE: @Async + custom pool + CF wrapping
// @Async("asyncPool")
// public CompletableFuture<Balance> getBalance(String id) {
// return CompletableFuture.supplyAsync(() -> repo.findById(id), asyncPool);
// }
// AFTER: simple blocking code on virtual thread
public Balance getBalance(String id) {
return repo.findById(id); // simple blocking call!
}
// Called from a virtual thread: blocking is free!
What does CompletableFuture still do better?
1. Composing independent parallel calls
CompletableFuture<Void> all = CompletableFuture.allOf(
supplyAsync(() -> getBalance()),
supplyAsync(() -> getCreditScore()),
supplyAsync(() -> getPropertyValue())
);
all.join();
// Virtual threads: manually start 3 threads + join them
// CF: cleaner composition with allOf/anyOf/thenCombine
2. Timeouts and fallbacks
cf.orTimeout(5, TimeUnit.SECONDS)
.exceptionally(ex -> fallbackValue);
// Virtual threads: no built-in timeout/fallback pipeline
What do platform pools still do better?
CPU-bound work needs all cores busy. Virtual threads do not speed up compute: they share the same carrier pool (cores threads).
// CPU-bound: platform pool still best
data.parallelStream().map(this::encrypt).sum(); // ForkJoinPool
// Virtual threads: no advantage for CPU-bound work
What is the summary?
| Workload | Best tool |
|---|---|
| IO-bound fan-out (10K requests) | Virtual threads |
| Compose/combine/timeout | CompletableFuture |
| CPU-bound parallelism | Platform thread pool / ForkJoinPool |
| Simple blocking call at scale | Virtual threads |
| Pipeline with fallbacks | CompletableFuture |
What are common mistakes with virtual threads?
// WRONG: pinning virtual thread with synchronized
synchronized (lock) {
blockingIO(); // virtual thread PINNED to carrier, cannot unmount!
}
// Use ReentrantLock instead:
lock.lock();
try { blockingIO(); } finally { lock.unlock(); }
// WRONG: thread-local caching in virtual threads
ThreadLocal<Connection> connCache = new ThreadLocal<>();
// Millions of virtual threads x 1 connection each = OOM!
// Use connection pooling (HikariCP) instead.
What are common interview follow-up questions?
- Do virtual threads replace thread pools? For IO-bound work, yes. For CPU-bound, no.
- Can you use CompletableFuture with virtual threads? Yes, pass a virtual thread executor:
supplyAsync(task, virtualExecutor). - What is thread pinning? When a virtual thread holds a monitor (synchronized), the JVM cannot unmount it from the carrier, blocking the carrier.
Visual Overview
Platform Thread Virtual Thread
┌──────────────┐ ┌──────────────┐
│ ~1MB stack │ │ ~1KB stack │
│ 1:1 with OS │ │ N:M on │
│ thread │ │ carriers │
│ Expensive │ │ Cheap │
│ Max ~200-500 │ │ Max millions │
└──────────────┘ └──────────────┘
Virtual Thread Blocking IO:
┌──────────┐ IO block ┌──────────┐
│ VT-1 │────────────▶ │ Unmounted │
│ on │ carrier │ VT-1 │
│ carrier-1│ freed │ (parked) │
└──────────┘ └──────────┘
│
▼
┌──────────┐
│ VT-2 │ ← carrier-1 now serves VT-2
│ on │
│ carrier-1│
└──────────┘
Key Takeaway
Virtual threads (Java 21) are lightweight threads that the JVM manages on a small pool of carrier platform threads. When a virtual thread blocks on IO, the JVM unmounts it, freeing the carrier for another virtual thread. This means simple blocking code scales to millions of concurrent tasks without async complexity.
Virtual threads replace thread pool tuning and @Async annotations for IO-bound fan-out. But CompletableFuture is still needed for composing independent parallel calls (thenCombine, allOf), timeouts (orTimeout), and fallbacks (exceptionally). Platform thread pools are still needed for CPU-bound parallelism because virtual threads share the same carrier pool.
The three tools complement each other. At National Bank of India: the API gateway uses virtual threads for 10K concurrent IO requests. Loan approval combining three parallel checks uses CompletableFuture. The encryption batch uses a platform thread pool with cores threads.
Remember one sentence: virtual threads handle IO scale, CF handles composition, and platform pools handle CPU parallelism.
---