The idea: don't hold an OS thread while you wait

A typical server request spends most of its time waiting: for the database, for another service, for the disk. With platform threads (every java.lang.Thread before Java 21), a waiting request still holds a whole OS thread and its 1 MB stack. So a server runs out of threads long before it runs out of CPU.

A virtual thread (Java 21, JEP 444, Project Loom) is a Thread that the JVM schedules itself. While it waits, it gives its OS thread back. The canvas runs the same requests both ways on a server with 2 CPUs: a JDBC query that waits 4 ms for the database between two 1 ms bits of CPU work.

A virtual thread's life: mounted on carrier-1 it runs, calls read() and unmounts, its frames copied to a stack chunk in the heap while the socket fd waits in the Read-Poller's epoll set; when data arrives it is unparked to the scheduler queue and mounted again, this time on carrier-2
A virtual thread runs on a carrier, moves its stack to the heap while it waits, and comes back on whichever carrier is free.

Platform threads: one OS thread per task

A platform thread is a thin wrapper around an OS thread (see From a Java Thread to a CPU Core). It is expensive: about 1 MB of reserved stack (-Xss1m), a kernel task, and a kernel context switch every time it blocks or wakes. So servers put them in a pool, like Executors.newFixedThreadPool(4) on the canvas (see Thread Pool: Java ThreadPoolExecutor).

When a pool thread calls read() on the database socket, the kernel puts it to sleep on the socket's wait queue. The thread keeps its task until the answer comes. With 8 requests and 4 threads, requests 5 to 8 wait in the executor queue while both CPUs sit idle. (Demo: 8 requests on a pool of 4 platform threads)

Virtual threads and carrier threads

With Executors.newVirtualThreadPerTaskExecutor(), every request gets its own virtual thread (VT). A VT is a heap object plus a stack of frames. It is cheap enough to create one per task and never pool them.

The VTs are run by a scheduler: a ForkJoinPool whose parallelism is the number of CPUs (here 2). Its worker threads are the carrier threads, ordinary platform threads named ForkJoinPool-1-worker-N. The kernel only ever sees the carriers, plus one Read-Poller thread.

Platform threadVirtual thread
Scheduled bythe kernelthe JVM (ForkJoinPool), on carriers
Stack1 MB reserved, fixedframes in the heap while parked, grows and shrinks
Createa system call (clone), ~10 µsan object allocation, ~1 µs
Blocking read()the OS thread sleepsthe VT unmounts, the carrier runs another VT
How manythousandsmillions
Pool them?yesno: one per task

Mount and unmount on blocking I/O

To run a VT, the scheduler mounts it on a free carrier: the VT's top frames are copied onto the carrier's stack and the carrier runs them. (Demo: one request, unmount and remount)

When the VT calls a blocking API, the JDK's socket code first tries a non-blocking read. No data yet, so the VT registers the socket's fd with the Read-Poller (an epoll set on Linux) and parks. Parking unmounts it: its frames are copied into a stack chunk object in the heap, and the carrier goes back to the queue for the next VT.

When the database answers, the poller's epoll_wait returns. The poller unparks the VT, which goes back on the scheduler queue. A free carrier mounts it again, often a different one. The code just sees read() return.

With 8 requests, all 8 queries wait at the same time on just 2 carriers, and the last request finishes at t = 9 ms instead of t = 14 ms with the pool of 4. (Demo: the same 8 requests on virtual threads)

Two timelines of the same 8 requests on 2 CPUs: with a pool of 4 platform threads the last request ends at 14 ms because requests 5 to 8 wait for a free thread; with virtual threads on 2 carriers all 8 queries overlap and the last ends at 9 ms
Same requests, same CPUs: virtual threads win because waiting no longer holds a thread.

Pinning: synchronized on JDK 21–23

A VT is pinned when it cannot unmount. On JDK 21 to 23 that happens when it blocks inside a synchronized block or method, because the monitor is tied to the carrier's identity. A pinned VT parks its carrier with it: the carrier sleeps in the kernel while it waits for the database.

With 4 requests whose JDBC driver reads the socket inside synchronized, both carriers are pinned. VT3 and VT4 wait 6 ms in the queue, and for 4 of those ms both CPUs are idle. The last request ends at t = 12 ms instead of t = 7 ms. (Demo: pinning (synchronized, JDK 21)) Find pinning with -Djdk.tracePinnedThreads=full (JDK 21–23) or the JFR event jdk.VirtualThreadPinned. The usual fix was to replace synchronized with a ReentrantLock, which does not pin.

JDK 24 (JEP 491) reworked monitors so a VT can unmount while it holds one. The same 4 requests then behave like plain queries. (Demo: JDK 24 unpins synchronized (JEP 491)) A VT is still pinned while a native method or a foreign function is on its stack.

What virtual threads do not do

They do not make CPU work faster. 4 tasks of 3 ms on 2 CPUs take 6 ms with any kind of thread. The scheduler also does not time-slice VTs: a VT keeps its carrier until it blocks or ends, so a long computation can delay other VTs. (Demo: CPU-bound work: no speed-up)

  • Don't pool virtual threads. To limit how many requests hit the database, use a Semaphore.
  • Be careful with big ThreadLocal caches: a million VTs means a million copies.
  • Some blocking calls (file I/O, Object.wait() on older JDKs) cannot unmount. The scheduler then adds a temporary extra carrier to keep the CPUs busy.

Go does the same thing with goroutines and its netpoller; see OS threads vs goroutines, side by side. For how a ForkJoinPool shares work between its workers, see Java ForkJoinPool and Work Stealing, and for the stack-switching idea behind it, Coroutines: Stackful vs Stackless.

What the page leaves out

The page uses 2 CPUs, 4 pool threads, fixed 1 ms CPU bursts and a 4 ms database, so the numbers are easy to check; real timings vary. It leaves out the ForkJoinPool's per-worker queues and work stealing, timed parking with Thread.sleep, the lazy copying of stack chunks (only the top frames move at each mount), thread-locals and scoped values, structured concurrency, the JDBC connection pool, and the kernel's own scheduling of the carriers.