Virtual Threads, In Depth
How Project Loom's virtual threads mount and unmount on carriers, the pinning pitfall, and why they change the blocking-vs-async calculus.
On this page
Virtual threads arrived in Java 21 and quietly changed how you should think about concurrency. You may have met them in the Spring path; here we go under the hood - how they work, where they help, and the one pitfall (pinning) that a senior engineer must know.
Platform threads vs. virtual threads
A traditional platform thread is a thin wrapper over an OS thread: expensive (about a megabyte of stack), limited to a few thousand, and wasted while blocked on I/O. A virtual thread is scheduled by the JVM, not the OS - it costs a few hundred bytes, you can have millions, and the JVM unmounts it from its carrier OS thread whenever it blocks:
// each request on its own virtual thread - millions are fine
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var request : requests)
executor.submit(() -> handle(request)); // blocking code, but cheap to block
}When handle blocks on a database or HTTP call, the virtual thread unmounts and its carrier OS thread is
freed to run another virtual thread. The blocking call looks blocking, but it no longer ties up a scarce
OS thread - so simple, readable, imperative code scales like async.
Why this changes the calculus
Before virtual threads, high I/O concurrency forced a hard choice: thread-per-request (simple but limited) or reactive/async (scalable but complex, hard to debug). Virtual threads mostly dissolve that tension: you write straightforward blocking code and get async-level scalability. For the majority of I/O-bound server workloads, virtual threads are now the simpler default, and reactive is reserved for genuine streaming and backpressure needs.
The pinning pitfall
The one thing to watch: a virtual thread can become pinned to its carrier - unable to unmount - if it
blocks while holding a synchronized monitor, or during a native call. A pinned thread ties up its OS
carrier, defeating the benefit under load:
// risk: blocking inside synchronized pins the virtual thread to its carrier
synchronized (lock) {
result = blockingIoCall(); // ← pinned! the carrier can't be reused
}
// prefer a ReentrantLock, which does not pin across a blocking call
lock.lock();
try { result = blockingIoCall(); }
finally { lock.unlock(); }On hot paths, prefer ReentrantLock over synchronized when a lock is held across blocking I/O. (Recent
JDKs continue to reduce pinning, but knowing the failure mode is the senior-level detail.)
Don't pool virtual threads
The instinct to reuse threads via a pool is wrong for virtual threads - they're so cheap that pooling adds contention for no benefit. Create one virtual thread per task (newVirtualThreadPerTaskExecutor()). Also, don't size them like platform threads; there's no small fixed budget to ration.
Platform threads are a lot with a fixed, small number of spots - once they're full, cars queue at the gate no matter how many are actually idling with engines off. Virtual threads are like valet parking with a small crew: a valet (carrier OS thread) parks a car (mounts a virtual thread), and the moment that car is just sitting and waiting, the valet leaves it and goes park another - one valet services far more cars than there are of them. Pinning is a car that grabs the valet's arm and won't let go while it idles (a synchronized block over a blocking call), stranding that valet and reintroducing the very bottleneck virtual threads remove.
If threads blocked on I/O are the problem, why not create a platform thread pool with 10,000 threads instead of using virtual threads? Explain what breaks, and what virtual threads do differently.
What is 'pinning' with virtual threads, and how do you avoid it?
Key takeaways
- Virtual threads (Java 21+) are JVM-scheduled, cost hundreds of bytes, and can number in the millions; a blocked one unmounts from its carrier OS thread.
- They let simple blocking code scale like async, mostly dissolving the thread-per-request vs. reactive trade-off for I/O-bound work.
- Create one virtual thread per task (newVirtualThreadPerTaskExecutor); don't pool them - they're too cheap to ration.
- Pinning: blocking inside a synchronized block (or a native call) prevents unmounting and ties up the carrier - prefer ReentrantLock on hot paths.
- For CPU-bound work virtual threads don't add cores; they help I/O-bound concurrency.