What are virtual threads (Project Loom) and how are they different from OS threads or green threads in older runtimes?
Virtual threads look exactly like ordinary Java threads, cost almost nothing to create by the millions, and quietly unmount from the OS thread underneath whenever they'd block.
LEARN BY DOINGPut your intuition to work3 steps
What are virtual threads (Project Loom) and how are they different from OS threads or green threads in older runtimes?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#The scaling wall of one-thread-per-request
The traditional JVM concurrency model maps each unit of concurrent work — typically one HTTP request — to one platform thread, a thin wrapper around a real OS thread. Platform threads are expensive: each reserves a fixed-size stack (1MB by default, tunable but rarely shrunk safely much below a few hundred KB), and the OS scheduler context-switches between them with genuine overhead — privilege-mode transitions, register saves, cache and TLB pollution. In practice a single JVM starts struggling somewhere in the low thousands of live threads: memory pressure from stacks alone, scheduler contention, and diminishing throughput per additional thread. That ceiling is exactly what pushed the industry toward async and reactive programming — callback chains, CompletableFuture pipelines, Project Reactor — not because engineers preferred it, but because it lets a small, fixed pool of platform threads serve far more concurrent operations by never blocking a thread on I/O. The well-known cost is that reactive code is harder to write, debug, and read a stack trace from than straight-line blocking code.
#What a virtual thread actually is
Project Loom, finalized as JEP 444 in Java 21, introduces virtual threads: lightweight threads implemented and scheduled by the JVM itself rather than the operating system. To your code they look exactly like java.lang.Thread — same API, same Thread.currentThread(), same thread-locals, same try/catch around blocking calls — but millions can exist concurrently, because each virtual thread's stack lives on the heap, starts at only a few hundred bytes, and grows or shrinks as needed. There is no dedicated OS thread backing each one. Instead, the JVM multiplexes many virtual threads onto a small pool of carrier threads — ordinary platform threads, by default the common ForkJoinPool sized to the number of available CPU cores.
The mechanism that makes this genuinely useful, not merely cheap to allocate: when a virtual thread executes a blocking call that the JDK has made virtual-thread-aware — blocking java.io/java.net socket operations, java.sql through an updated JDBC driver, java.util.concurrent locks, Thread.sleep — it unmounts from its carrier thread instead of blocking it. The carrier is immediately freed to run a different virtual thread. When the blocking operation completes, the original virtual thread is remounted, possibly onto a different carrier, and resumes exactly where it left off. From the code's point of view nothing changed — it is still an ordinary, blocking, top-to-bottom method call — but the platform thread underneath was never left idle.
#Why this is not the same as old-style green threads
Pre-1.3 Java shipped "green threads": multiple lightweight threads scheduled entirely by the JVM onto a single underlying OS thread. That gave concurrency, but never real parallelism — only one green thread's instructions could execute at any physical instant, on any CPU. Worse, a blocking syscall that the runtime did not specifically know how to make asynchronous would stall the one OS thread underneath, freezing every other green thread along with it. Early JVMs abandoned the model for exactly that reason, moving to native, OS-scheduled (1:1) threads by Java 1.3. Virtual threads bring the lightweight-thread idea back but fix both problems at once: they schedule genuinely in parallel across a pool of multiple carrier threads (M:N scheduling, not M:1), and the JDK's own blocking I/O APIs are integrated with the virtual thread scheduler so a blocking call yields its carrier instead of stalling it.
| Model | Scheduled by | Stack | Practical max count | Blocking I/O |
|---|---|---|---|---|
| Platform thread | OS kernel | Fixed, ~1MB | Low thousands | Blocks the OS thread |
| Old-style green thread | JVM, onto one OS thread | Fixed, smaller | Thousands, but no parallelism | Blocks the single underlying OS thread, freezing all of them |
| Virtual thread (Loom) | JVM, onto N carrier threads | Heap-allocated, grows from ~hundreds of bytes | Millions | Unmounts the carrier, resumes later |
#Code
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {List<Future<String>> futures = IntStream.range(0, 100_000).mapToObj(i -> executor.submit(() -> fetchUser(i))).toList();for (var future : futures) {String result = future.get();handle(result);}} // executor.close() blocks until every submitted task has finished
Thread worker = Thread.ofVirtual().name("order-worker-", 0).unstarted(() -> processOrder(orderId));worker.start();worker.join();
The first example submits 100,000 tasks, each doing a blocking call, one virtual thread per task, all backed by a handful of carrier threads. Try the equivalent with Executors.newFixedThreadPool(100_000) platform threads and the JVM runs out of stack memory long before it runs out of CPU.
🧠 Check your understanding
0/3 · 0/3 answered1. What specifically lets a virtual thread avoid blocking its underlying OS thread during I/O?
2. Why couldn't classic pre-1.3 Java green threads deliver real parallelism?
3. What is "pinning" a virtual thread, and why does it matter?