Topic 13.10
Virtual Threads
In one line
Virtual threads (Java 21, JEP 444) are threads managed by the JVM instead of the operating system. They're so cheap that you can start one per task, even a million of them, and write simple blocking code that scales like asynchronous code.
Think of it like this
A restaurant used to give every table its own waiter (a platform thread), who stood beside the table even while the kitchen cooked. A busy evening needed hundreds of waiters. Now a few waiters (carrier threads) serve notepads (virtual threads): when a table is waiting for food, its notepad is set aside and the waiter serves another table, picking the notepad up again when the food is ready. Thousands of tables, a handful of waiters.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Virtual thread
- A lightweight Java thread scheduled by the JVM, cheap enough to create one per task.
- Platform thread
- A classic Java thread backed one-to-one by an operating-system thread.
- Carrier thread
- A platform thread that runs virtual threads, one at a time, switching between them when they block.
- Mount / unmount
- Putting a virtual thread onto a carrier to run, and taking it off (saving its stack) when it blocks.
- Pinning
- When a virtual thread can't be unmounted while blocked, so it keeps its carrier busy.
- I/O-bound
- Work that spends most of its time waiting for disks, networks or databases, not using the CPU.
- Throughput
- How much work completes per second.
Step by step
01Why threads were the bottleneck
A web server handling each request on its own thread needs one thread per concurrent request. With 10,000 users waiting on slow databases, that's 10,000 OS threads, which costs too much memory and scheduling. The workaround was asynchronous callback code (Topic 13.7), which is harder to write, read and debug.
Virtual threads keep the simple one-thread-per-request style but make the threads cheap.
02Mounting and unmounting
The scheduler mounts a virtual thread on a free carrier. When the code calls a blocking operation that the JDK has made virtual-thread-aware (most of java.io, java.net, java.util.concurrent, Thread.sleep), the virtual thread's frames are copied to the heap and the carrier picks up another virtual thread.
03Using them
Replace a fixed pool with Executors.newVirtualThreadPerTaskExecutor() in a try-with-resources block (closing it waits for all tasks). Write ordinary blocking code inside each task.
For a single thread: Thread.ofVirtual().name("worker-", 0).start(task); for platform threads with the same builder style, Thread.ofPlatform().
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (Request r : requests) {
exec.submit(() -> handle(r)); // one cheap thread per request, blocking is fine
}
} // waits for all of them04Limit resources with a semaphore, not a pool
If a database allows only 20 connections, a virtual-thread executor could start 10,000 tasks that all try to connect. Guard the connection with Semaphore(20): tasks beyond 20 wait cheaply, and the database is protected.
05When they don't help
CPU-bound loops never block, so they occupy a carrier until done; with more such tasks than cores, they queue. Parallel streams or a ForkJoinPool are the right tools there (Topic 10.8).
Code that blocks in native methods or (before Java 24) inside synchronized pins its carrier. -Djdk.tracePinnedThreads=full printed pinning stack traces in Java 21; JDK Flight Recorder records jdk.VirtualThreadPinned events (Topic 14.5).
Try it yourself
- 1
Count the carriers
On your own JDK 21+, print
Runtime.getRuntime().availableProcessors()and run the first example with 100,000 tasks. It still finishes quickly: blocked virtual threads don't need carriers. - 2
Try platform threads
On your own JDK, replace the executor with
Executors.newFixedThreadPool(100). The 10,000 sleeps now take about 10,000 / 100 × 50 ms = 5 seconds, because only 100 tasks can wait at once.
Code & diagrams
With 10,000 platform threads this would use gigabytes of stack; with virtual threads it finishes in well under a second on a normal laptop.
Expected output
10,000 tasks finished, sum = 49995000Expected output
inside: virtual? true
inside: virtual? false
worker-1 daemon? trueExpected output
200 queries done; at most 5 connections at once: trueBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Expecting CPU-bound work to speed up
Run 10,000 tasks that each compute a big sum (no blocking) on a virtual-thread executor.
Myth vs fact
Myth
Virtual threads make Java code faster.
Fact
They raise throughput for I/O-bound work by letting more tasks wait at once. A single task isn't faster, and CPU-bound work isn't helped.
Myth
You should pool virtual threads.
Fact
They're meant to be created per task and thrown away. Use a Semaphore to limit access to resources.
Myth
Virtual threads replace the Thread class.
Fact
They are Thread objects with the same API; only how they're scheduled differs.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Virtual threads are implemented with continuations: the JVM copies the frames of a blocked virtual thread to a heap-allocated stack chunk on unmount and lazily copies them back on mount, so memory grows with actual stack depth, not a fixed reservation.
- ▸
The default scheduler is a FIFO-mode
ForkJoinPool; its parallelism can be tuned with-Djdk.virtualThreadScheduler.parallelism, but you rarely should. - ▸
Thread dumps of millions of threads are huge;
jcmd <pid> Thread.dump_to_file -format=json file.json(Java 21) writes a structured dump that groups virtual threads by their container. - ▸
Synchronous blocking APIs that use
Object.wait()or native code can still pin; JEP 491 (Java 24) fixed pinning forsynchronized, but pinning in native frames remains.
Remember this
- 1
A platform thread (
new Thread, Topics 13.1 and 13.6) wraps an operating-system thread: it reserves a large stack (often about 1 MB) and the OS schedules it. A few thousand is a practical limit. A virtual thread is a Java object whose stack lives on the heap and grows as needed. The JVM runs it on a small pool of carrier threads (aForkJoinPoolsized to the CPU count). - 2
When a virtual thread blocks (sleep, a socket read,
BlockingQueue.take, a lock), the JVM unmounts it: saves its stack and frees the carrier to run another virtual thread. When the I/O completes, the virtual thread is mounted again, possibly on a different carrier. Blocking becomes cheap, so the thread-per-request style scales to huge numbers of concurrent requests. - 3
Create them with
Thread.ofVirtual().start(task),Thread.startVirtualThread(task), or best, **Executors.newVirtualThreadPerTaskExecutor(), which starts a new virtual thread for every submitted task. They're always daemon** threads and have the normalThreadAPI, so existing code, debuggers and thread dumps work with them. - 4
Virtual threads help I/O-bound work (waiting on databases, HTTP calls, files): throughput rises because thousands of requests can wait at once. They don't make CPU-bound work faster: there are still only as many carriers as cores. Use parallel streams or a fixed pool for number crunching.
- 5
Don't pool virtual threads (create one per task; they're cheap). Limit access to scarce resources with a
Semaphoreinstead of a small pool. Be careful withThreadLocalcaches: with a million threads, a per-thread cache becomes a million caches. Before Java 24, blocking insidesynchronizedpinned the virtual thread to its carrier (the carrier couldn't be freed), so code usedReentrantLock; JEP 491 in Java 24 removed most pinning. - 6
Frameworks adopted them quickly: Spring Boot 3.2+ can serve requests on virtual threads (
spring.threads.virtual.enabled=true), as can Tomcat, Jetty and Helidon. For most apps, switching on virtual threads plus removing blocking-avoidance hacks is the whole migration.
Explain it without notes
What is a virtual thread and how is it different from a platform thread?
Why don't virtual threads speed up CPU-bound work?
How should you limit concurrency when using virtual threads?
Practice
Write a program that starts 1,000 virtual threads with Thread.ofVirtual(), each adding 1 to an AtomicInteger, joins them all and prints the total.
Trade-offs
- ↔
Virtual threads give simple blocking code with high concurrency, but don't help CPU-bound work and make per-thread caches (ThreadLocal) costly.
- ↔
Callback pipelines (CompletableFuture, reactive libraries) offer fine control and back-pressure but are harder to write and debug than straight-line code on virtual threads.
Done when you can
Done when you can explain carriers, mounting and unmounting in plain words.
Done when you can run tasks with newVirtualThreadPerTaskExecutor and Thread.ofVirtual().
Done when you know virtual threads help I/O-bound work, not CPU-bound work.
Done when you limit resources with a Semaphore instead of pooling virtual threads.