Why this matters
- The thread-per-request model is the simplest way to write server code, and virtual threads make it scale again — you no longer have to choose between readable code and throughput.
- Asynchronous and reactive code exists largely to avoid blocking OS threads. Remove that constraint and much of the complexity is no longer justified.
- The failure mode, pinning, is specific and avoidable, and knowing it is the difference between virtual threads helping and quietly not helping.
The problem with platform threads
// Each task holds a real OS thread for its entire duration, including the waiting
ExecutorService pool = Executors.newFixedThreadPool(200);
for (int i = 0; i < 10_000; i++) {
pool.submit(() -> {
Response response = httpClient.send(request); // ~100 ms of doing nothing
return process(response);
});
}
A platform thread reserves a stack — typically around a megabyte of address space — and its creation and context switching go through the kernel. A few thousand is the practical ceiling, which is why pools exist. And when a pooled thread blocks on I/O, it is simply unavailable: 200 threads each waiting 100 ms means at most 2,000 requests per second regardless of how little CPU the work needs.
Virtual threads
// One virtual thread per task — a million is reasonable
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
Response response = httpClient.send(request); // blocks the virtual thread only
return process(response);
});
}
} // close() waits for every task — the executor is an AutoCloseable
Virtual threads help throughput for input/output-bound work. They do not make processor-bound code any faster.
// Other ways to create one
Thread.startVirtualThread(() -> doWork());
Thread virtual = Thread.ofVirtual().name("worker-1").unstarted(runnable);
virtual.start();
Thread platform = Thread.ofPlatform().daemon().start(runnable); // the same builder API
System.out.println(Thread.currentThread().isVirtual());
How it works
A synchronized block can pin a virtual thread to its carrier. Prefer ReentrantLock in code that runs on virtual threads.
The mechanism in four steps
- A virtual thread runs on a carrier thread, taken from a dedicated
ForkJoinPoolsized to the number of cores. - When it hits a blocking operation that the JDK has adapted — socket I/O,
sleep, a lock, aBlockingQueue— the JVM unmounts it, copying its stack to the heap and freeing the carrier. - The carrier immediately picks up another runnable virtual thread. No OS thread is idle.
- When the operation completes, the virtual thread is remounted, possibly on a different carrier, and continues from exactly where it stopped.
This is why blocking is cheap: the cost of blocking a virtual thread is a stack copy on the heap, not a parked OS thread.
| Aspect | Platform thread | Virtual thread |
|---|---|---|
| Backed by | One OS thread | A heap-allocated stack, mounted on a carrier when running |
| Stack | Fixed, around 1 MB reserved | Grows and shrinks on the heap, starts tiny |
| Practical count | Thousands | Millions |
| Creation cost | A kernel call — pool them | Roughly an object allocation — never pool them |
| Cost of blocking | An OS thread sits idle | Near zero — the carrier is released |
| Scheduling | Pre-emptive, by the OS | Cooperative, by the JVM at blocking points |
| Good for | CPU-bound work | I/O-bound work |
| Priorities and thread groups | Supported | Ignored; always daemon, always normal priority |
Backed by
Platform threadOne OS threadVirtual threadA heap-allocated stack, mounted on a carrier when runningStack
Platform threadFixed, around 1 MB reservedVirtual threadGrows and shrinks on the heap, starts tinyPractical count
Platform threadThousandsVirtual threadMillionsCreation cost
Platform threadA kernel call — pool themVirtual threadRoughly an object allocation — never pool themCost of blocking
Platform threadAn OS thread sits idleVirtual threadNear zero — the carrier is releasedScheduling
Platform threadPre-emptive, by the OSVirtual threadCooperative, by the JVM at blocking pointsGood for
Platform threadCPU-bound workVirtual threadI/O-bound workPriorities and thread groups
Platform threadSupportedVirtual threadIgnored; always daemon, always normal priority
Virtual threads do not make CPU-bound work faster — there are still only as many cores as there are cores.
Stop pooling
// Wrong — a pool of virtual threads caps concurrency for no benefit
ExecutorService wrong = Executors.newFixedThreadPool(200, Thread.ofVirtual().factory());
// Right — one virtual thread per task
ExecutorService right = Executors.newVirtualThreadPerTaskExecutor();
// Limit the scarce resource, not the threads
private final Semaphore dbPermits = new Semaphore(20);
void handle(Request request) throws InterruptedException {
dbPermits.acquire();
try {
database.query(request);
} finally {
dbPermits.release();
}
}
Pinning — the one real trap
A virtual thread cannot unmount in two situations, so it holds its carrier while blocked.
// 1. Inside a synchronized block that blocks (fixed in Java 24; still relevant on 21)
synchronized (lock) {
Response response = httpClient.send(request); // pinned: the carrier is held
}
// 2. Inside a native method or a foreign function call
// The fix on Java 21: use a ReentrantLock, which is virtual-thread aware
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
Response response = httpClient.send(request); // unmounts correctly
} finally {
lock.unlock();
}
// Also replace ThreadLocal caching patterns — with a million threads, a million copies
// Scoped values (a preview API in 21) are the intended replacement for context propagation.
// Until they are final, prefer passing context explicitly as a parameter.
Structured concurrency
The companion feature, still a preview API in Java 21, gives a scope a defined lifetime so related tasks succeed or fail together.
// Preview in Java 21 — enable with --enable-preview
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Supplier<User> user = scope.fork(() -> loadUser(id));
Supplier<List<Order>> orders = scope.fork(() -> loadOrders(id));
scope.join(); // wait for both
scope.throwIfFailed(); // if either failed, the other was cancelled
return new Dashboard(user.get(), orders.get());
}
What this means for application design
// On Java 21, straightforward blocking code on a virtual thread is often
// clearer than the equivalent CompletableFuture chain — and performs comparably.
User user = userService.load(id); // blocks, cheaply
List<Order> orders = orderService.recent(user); // blocks, cheaply
return new Dashboard(user, orders);
What changes, and what does not
- Thread-per-request is viable again. A servlet or Spring MVC controller on a virtual thread scales like a reactive one for I/O-bound work.
- Debugging gets better. A virtual thread has an ordinary stack trace, so a thread dump shows the whole logical operation. A reactive chain does not.
- CPU-bound work is unaffected. Use a bounded platform-thread pool for computation.
- Connection pools and rate limits still matter. Removing the thread ceiling exposes whatever the next bottleneck is, usually the database.
- Existing blocking libraries mostly just work, because the JDK adapted the blocking points underneath them.
Common misreadings
- "Virtual threads make everything faster." They improve throughput for I/O-bound work. CPU-bound work is bounded by cores.
- "Pool them like platform threads." Never. Create one per task.
- "A virtual thread is a lightweight OS thread." It is managed entirely by the JVM; the OS does not know it exists.
- "Blocking is still bad." Blocking a virtual thread is the intended way to use it.
- "
synchronizedis fine." On Java 21 it pins the carrier during blocking I/O. PreferReentrantLock. - "
ThreadLocalworks the same." It works, but a million threads means a million copies. - "Structured concurrency is production-ready in 21." It is a preview API whose shape is still changing.
- "You must rewrite your code." Most blocking code runs unchanged; the audit is for pinning and downstream limits.
Quick recall
Everything you need if you only revisit this box.
- A virtual thread is scheduled by the JVM onto a carrier thread from a core-sized
ForkJoinPool; it unmounts at blocking points and remounts afterwards. - Millions are practical: the stack lives on the heap and grows on demand.
- Never pool virtual threads. Use
Executors.newVirtualThreadPerTaskExecutor(), and aSemaphoreto limit a scarce resource. - They help I/O-bound work. CPU-bound work still needs a bounded platform pool.
- Pinning holds the carrier: caused by blocking inside
synchronized(on 21) or in native code. Fix withReentrantLock; detect with-Djdk.tracePinnedThreads=full. - Virtual threads are always daemon, ignore priorities, and cannot be in a thread group.
ThreadLocalstill works but scales badly; scoped values are the eventual replacement.- Structured concurrency (
StructuredTaskScope) is a preview API in 21 — understand it, do not ship it yet.
Test yourself
Answer these before moving on — recall is what makes it stick.