How it works
The explanation, with examples you can run.
Writing blocking code that scales
import java.time.*;import java.util.concurrent.*;import java.util.stream.*; public class Many { public static void main(String[] args) { Instant start = Instant.now(); try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { IntStream.range(0, 10_000).forEach(i -> executor.submit(() -> { Thread.sleep(Duration.ofSeconds(1)); // parks the virtual thread, frees the carrier return i; })); } // close() waits for every task System.out.println("10,000 tasks in " + Duration.between(start, Instant.now()).toMillis() + " ms"); }}Ten thousand tasks that each sleep for a second finish in about one second. With a pool of 200 platform threads they would take 50 seconds; with 10,000 platform threads you would spend gigabytes of stack reservations. You can also start one directly with Thread.ofVirtual().start(runnable) or Thread.startVirtualThread(runnable).
How it works
The scheduler is a work-stealing ForkJoinPool with, by default, as many carriers as there are processors. A virtual thread runs by being mounted on a carrier. When it calls a blocking operation that the JDK has adapted, such as socket and file I/O, Thread.sleep, BlockingQueue.take or ReentrantLock.lock, the JDK saves its stack frames to the heap (as stack chunk objects the GC manages) and unmounts it. When the operation can continue, the thread is mounted again, possibly on a different carrier. Your code sees an ordinary blocking call, with an ordinary stack trace in the debugger.
That is why the gain is throughput, not speed. A CPU-bound task on a virtual thread takes as long as on a platform thread. The win comes when most tasks spend most of their time waiting.
Rules of thumb
- Don't pool them. A pool exists to share something expensive, and virtual threads aren't. Use one per task.
- Limit resources, not threads. A database with 50 connections still allows 50. Guard scarce resources with a
Semaphoreor the connection pool itself. - Go easy on ThreadLocal. It works, but a million threads each caching a big object is a million big objects. Java 21 previews scoped values (JEP 446) as an alternative.
- Avoid pinning in hot paths. In Java 21 a virtual thread that blocks inside
synchronized, or in a native method, is pinned: its carrier blocks with it. That is correct but can starve the scheduler. Run with-Djdk.tracePinnedThreads=fullor watch the JFRjdk.VirtualThreadPinnedevent, and swapsynchronizedfor aReentrantLockwhere blocking happens inside. JEP 491 removed thesynchronizedcase in Java 24.
Virtual threads are always daemon threads and ignore priorities. Java 21 also previews structured concurrency (JEP 453), which treats a group of virtual threads started for one request as a single unit of work.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.