library
advancedVirtual Threads (Java 21)
Use lightweight virtual threads for high-concurrency I/O workloads without the overhead of platform threads.
A virtual thread is scheduled by the JVM, not the operating system, so you can have millions of them.
Platform threads = taxis (expensive, limited supply). Virtual threads = rideshare (millions of passengers, shared small car fleet that picks up whoever is ready to move).
Key Concepts
1
Thread.ofVirtual().start(() -> handle(request));2
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (var task : tasks) pool.submit(task);
}3
A platform thread maps 1:1 to an OS thread and costs about a megabyte of stack plus OS scheduling. Ten
thousand of them is already painful; a million is impossible.
4
A virtual thread costs a few hundred bytes. When it blocks, the JVM unmounts it from its carrier thread,
stores its stack on the heap, and runs something else. No OS thread sits waiting.
5
platform threads limited, expensive, block an OS thread
virtual threads unlimited in practice, cheap, block only themselves6
Which is why you can go back to writing blocking code.
// this scales now
var user = userApi.fetch(id); // blocks
var orders = orderApi.fetch(id); // blocks
return new Profile(user, orders);7
Before Loom, scaling that meant CompletableFuture chains or a reactive framework, with the control flow
turned inside out and stack traces to match.
CompletableFuture
8
Do not pool them. A thread pool exists to share something expensive. Virtual threads are cheap, so one
per task is the model, and newVirtualThreadPerTaskExecutor is the executor to use.
newVirtualThreadPerTaskExecutor
9
They are for blocking, not for computation. A CPU-bound task occupies its carrier the whole time, so a
million of them buys nothing — the parallelism is still your core count. Use the common ForkJoinPool there.
ForkJoinPool
10
Pinning is the limitation to know. A virtual thread blocking inside a synchronized block cannot be
unmounted, so it holds its carrier:
synchronized
11
synchronized (lock) { io.read(); } // pins the carrier
lock.lock(); try { io.read(); } ... // ReentrantLock does not12
Prefer ReentrantLock over synchronized around blocking I/O. JDK 24 removes most pinning, but the advice
still holds on 21.
ReentrantLocksynchronized
13
ThreadLocal works and is worth avoiding at a million threads — a million copies of each value. Scoped
values are the intended replacement.
ThreadLocal
14
What the interviewer is probing.1. "What problem do virtual threads solve?" Probing: whether you can state it in terms of
cost. Stalls: "They are faster." Moves up: a platform thread costs about a megabyte and an OS
scheduling slot, so blocking code could not scale; a virtual thread costs a few hundred bytes and
unmounts when it blocks.
15
2. "Should you pool virtual threads?" Probing: the model change. Stalls: "Yes, pools are
good practice." Moves up: no — a pool exists to share something expensive, and these are cheap.
One per task, via newVirtualThreadPerTaskExecutor.
16
3. "What is pinning?" Probing: the limitation that matters on 21. Stalls: "Nothing I know
of." Moves up: a virtual thread blocking inside synchronized cannot unmount and holds its carrier;
prefer ReentrantLock around blocking I/O.
17
4. "Do virtual threads speed up CPU-bound work?" Probing: whether you know what they do not
fix. Stalls: "Yes, more threads." Moves up: no — parallelism is still bounded by cores; they
help when threads are blocked, not when they are computing.