Command Palette

Search for a command to run...

PHASE 13Advanced Java 21+ ~14 min· topic 10 of 11

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.

Mounting and unmountingdiagram
Rendering diagram…

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().

Main.javawhole filejava
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 them

04Limit 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. 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. 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

Ten thousand blocking tasks Java 21+ New tab

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.

This example needs Java 21+. The in-browser compiler is Java 17: install JDK 21 or newer and run it with "java Main.java".

Expected output

10,000 tasks finished, sum = 49995000
Virtual or platform? Java 21+ New tab
This example needs Java 21+. The in-browser compiler is Java 17: install JDK 21 or newer and run it with "java Main.java".

Expected output

inside: virtual? true
inside: virtual? false
worker-1 daemon? true
Protect a scarce resource with a Semaphore Java 21+ New tab
This example needs Java 21+. The in-browser compiler is Java 17: install JDK 21 or newer and run it with "java Main.java".

Expected output

200 queries done; at most 5 connections at once: true

Break 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.

terminal
$ time the program
── what you'll see ──
About the same total time as a fixed pool with one thread per core.

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 for synchronized, but pinning in native frames remains.

Remember this

  1. 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 (a ForkJoinPool sized to the CPU count).

  2. 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. 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 normal Thread API, so existing code, debuggers and thread dumps work with them.

  4. 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. 5

    Don't pool virtual threads (create one per task; they're cheap). Limit access to scarce resources with a Semaphore instead of a small pool. Be careful with ThreadLocal caches: with a million threads, a per-thread cache becomes a million caches. Before Java 24, blocking inside synchronized pinned the virtual thread to its carrier (the carrier couldn't be freed), so code used ReentrantLock; JEP 491 in Java 24 removed most pinning.

  6. 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

01

What is a virtual thread and how is it different from a platform thread?

02

Why don't virtual threads speed up CPU-bound work?

03

How should you limit concurrency when using virtual threads?

Practice

01

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.