Explanation
What just happened
Three virtual threads ran on two real OS threads, called carriers. A virtual thread only occupies a carrier while it’s actually running. When vt-1 and vt-2 called sleep(), they unmounted, and carrier-1 picked up vt-3. After the sleeps, vt-1 and vt-2 remounted, on swapped carriers. For a moment all three were asleep and both carriers were free.
Try the what-if switch
2 platform threads: the same three tasks on newFixedThreadPool(2). A sleeping task keeps its OS thread asleep with it, so task 3 can’t start until task 1 is completely done. The work finishes at 8 instead of 5.
Why this matters
A platform thread that blocks on I/O holds an OS thread doing nothing. That’s why servers needed thread pools and callbacks. A virtual thread that blocks costs almost nothing, so you can write simple blocking code and still run thousands of tasks at once.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var request : requests) executor.submit(() -> handle(request));
}
Good to know
- Don’t pool virtual threads. They’re cheap: create one per task.
- They help with waiting (I/O, sleep, locks), not with CPU-heavy work. Computing still needs real cores.
- To limit load on a database or API, use a
Semaphore, not a small pool. - Pinning: before JDK 24, blocking inside
synchronizedkept the virtual thread stuck to its carrier. JDK 24 removed that limit (JEP 491). Native calls still pin. Threadline’s tests check this on JDK 21 and JDK 25.
Common questions
What is a carrier thread?
A platform (OS) thread that runs virtual threads. A virtual thread is mounted on a carrier only while it runs. When it blocks, it unmounts, and may resume later on a different carrier.
What is the difference between virtual threads and platform threads?
A blocked platform thread holds its OS thread doing nothing. A blocked virtual thread frees its carrier for other work, so a few carriers can serve thousands of waiting tasks.
Does synchronized pin virtual threads?
Before JDK 24, yes: blocking inside synchronized kept the virtual thread stuck to its carrier. JDK 24 removed that (JEP 491). Threadline's tests check both on JDK 21 and JDK 25.
Should I pool virtual threads?
No. They're cheap, so create one per task with Executors.newVirtualThreadPerTaskExecutor(). To limit load on a database or API, use a Semaphore.
Verified: this timeline matches a real program, checked in CI on JDK 21 and 25. How we verify. Unclear or wrong? Tell us