For twenty years the cost of a thread decided the shape of every concurrent Java program. Virtual threads changed the price. This is what they are, how the JVM actually runs them, and what you give up.
For twenty years, Java concurrency was organized around a single inconvenient fact: a thread was expensive, so you were not allowed to let one sit and wait.
Everything downstream of that fact was a workaround. Thread pools existed to
ration threads. Reactive streams, callback chains and CompletableFuture
pipelines existed so a thread could be handed back the instant it would
otherwise have blocked. We accepted inverted control flow and useless stack
traces, and we called it the cost of scaling.
Virtual threads, final since JDK 21, change the price. A thread that waits is no longer expensive, which means the workarounds are no longer mandatory.
This post is the foundation of a three part series: what a virtual thread is, how the JVM runs one, and what the trade-offs actually are. The later parts cover choosing an executor, and what to do when a million cheap threads all reach for the same database.
Concurrency and parallelism get used interchangeably, and the difference matters here. Parallelism is doing several things at the same instant, which needs several CPU cores. Concurrency is making progress on several things over the same period, which mostly needs the ability to put something down and pick it up again.
Server code is overwhelmingly the second kind. Look at where a typical request actually spends its time: a few milliseconds parsing and rendering, and tens of milliseconds doing nothing but waiting for a database or an API to answer.
The work did not get faster. The waiting got overlapped. That is the entire value proposition of concurrency for a backend service, and it means your throughput ceiling is set by how many things you can afford to have waiting at once.
In Java, the thing that waits is a thread. So the cost of a thread has always been the cost of concurrency itself.
Every Thread before JDK 21 was what we now call a platform thread: a thin
wrapper over an operating system thread. Creating one is a system call. Its stack
is reserved by the OS in whole megabytes, and the kernel scheduler decides when
it runs.
None of that is affordable per request, so we pooled them and capped how much work could be in flight.
That ceiling is arithmetic, not opinion. If a request spends 100ms waiting, one thread can finish about 10 of them per second. Two hundred threads therefore top out near 2,000 requests per second, and no amount of spare CPU changes that, because every one of those threads is asleep in a socket read holding a megabyte of stack. (The relationship has a name, Little’s Law, but the arithmetic is the part worth carrying around.)
Raising the pool size is not a fix, because you are now paying gigabytes of stack and kernel scheduling overhead for threads whose job is to do nothing. That dead end is the reason asynchronous programming took over.
A virtual thread is a real java.lang.Thread. Same class, same API. What it is
not is a wrapper around an OS thread.
Instead the JVM keeps a small pool of platform threads, called carriers, and
schedules virtual threads onto them. That scheduler is a dedicated work-stealing
ForkJoinPool running in FIFO mode, separate from the common pool that backs
parallel streams. Its parallelism defaults to Runtime.getRuntime().availableProcessors()
and can be set with jdk.virtualThreadScheduler.parallelism.
The critical difference is where the stack lives. A platform thread’s stack is an OS allocation. A virtual thread’s stack frames live on the garbage-collected Java heap, which is what makes suspending one cheap.
Start from the thing the operating system knows about, which is nothing. The OS has no concept of a virtual thread. Platform threads remain the only unit of OS-level scheduling, and everything below is the Java runtime moving work between them without the kernel ever being told.
To run your code, the runtime mounts the virtual thread onto a carrier by copying the stack frames it needs from the heap onto that carrier’s stack. The carrier is borrowed, not assigned. When the virtual thread hits a call that would block, the runtime unmounts it: the modified frames are copied back to the heap, and the carrier is released to go run something else.
Two details make this more than an optimization.
The first is that it was retrofitted across nearly every blocking point in the
JDK, not bolted onto a new API. Thread.sleep, socket reads, HttpClient and
the rest already know how to unmount instead of parking an OS thread, which is
why the fetcher example later in this post needs no special client and no
annotations.
The second is that the whole exchange is invisible to your code. There is no way
to discover which carrier you are running on, and the carrier’s own ThreadLocal
values are not visible to the virtual thread riding it. Your method resumes on the
statement after the blocking call with its locals and its stack intact, so the
code still reads top to bottom and the stack trace still describes your program.
That is the difference from a callback, and it only holds because nothing leaks
about the carrier underneath.
The cleanest way to hold all of this is an analogy from Modern Concurrency in Java: virtual threads are to platform threads what virtual memory is to physical memory. Virtual memory gives a process the illusion of far more address space than the machine has, by paging inactive pages out to disk. Virtual threads give you the illusion of effectively unlimited threads, by paging the stacks of inactive ones out to the heap. Same trick, one level up.
It also explains a smaller convenience: because the stack grows and shrinks on the heap, you never have to guess a thread’s stack size up front the way you do when the OS wants a fixed reservation.
At scale the shape is: unbounded virtual threads, a carrier pool that stays near your core count.
| Platform thread | Virtual thread | |
|---|---|---|
| Backed by | An OS thread | A heap object, scheduled onto a carrier |
| Created by | System call | JVM, in user space |
| Stack | Reserved by the OS, ~1 MB | On the GC heap, a few hundred bytes to start, grows as needed |
| Scheduled by | OS kernel | JVM’s ForkJoinPool scheduler |
| Cost of blocking | The whole OS thread is idle | A heap object is idle, the carrier moves on |
| Practical count | Thousands | Millions |
The footprint claim is easy to check rather than take on faith:
import java.util.concurrent.CountDownLatch;
public final class HowManyThreads {
public static void main(String[] args) throws Exception {
int target = Integer.parseInt(args[0]);
var ready = new CountDownLatch(target); // one count-down per started thread
var gate = new CountDownLatch(1); // keeps every thread parked
long t0 = System.nanoTime();
for (int i = 0; i < target; i++) {
Thread.ofVirtual().start(() -> {
ready.countDown();
try {
gate.await(); // park here until the end
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
ready.await(); // every thread has actually run
long ms = (System.nanoTime() - t0) / 1_000_000;
var rt = Runtime.getRuntime();
System.out.printf("%,d threads started in %,d ms, heap used %,d MB%n",
target, ms, (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024));
gate.countDown();
}
}
The ready latch matters: without it you measure how fast the loop submits work,
not how long until a million threads are actually running. On JDK 21.0.10 this
prints, consistently across runs:
$ java -Xmx2g HowManyThreads.java 1000000
1,000,000 threads started in 912 ms, heap used 837 MB
Under a second to have a million live threads, at roughly 870 bytes each once you
count the Thread object and the lambda alongside the continuation. The ceiling
is heap, not the operating system, and it moves with your stack depth rather than
being fixed.
Swapping Thread.ofVirtual() for Thread.ofPlatform() is not a benchmark so much
as a way to destabilize the machine, since each platform thread asks the OS to
reserve about a megabyte of stack. The loop gives out long before it gets close.
Two caveats, because this benchmark flatters virtual threads more than your service will. Those threads hold nothing, while a real request retains a parsed body, a session and a half-built response, which is usually far larger than the continuation itself. And the number is a capability, not a target: starting a million threads does not make any downstream serve more than it did yesterday.
Fetch several URLs concurrently, one virtual thread per URL, using nothing but the JDK.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse.BodyHandlers;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public final class Fetcher {
record Page(String url, int status, int bytes) {}
public static void main(String[] args) throws Exception {
List<String> urls = List.of(
"https://openjdk.org/jeps/444",
"https://openjdk.org/jeps/491",
"https://openjdk.org/jeps/425");
var http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
List<Future<Page>> results;
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
results = urls.stream()
.map(url -> executor.submit(() -> fetch(http, url)))
.toList();
} // close() waits for every task to finish
for (var future : results) {
Page p = future.get();
System.out.printf("%3d %,8d bytes %s%n", p.status(), p.bytes(), p.url());
}
}
static Page fetch(HttpClient http, String url) throws Exception {
var request = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(10))
.GET()
.build();
// blocks this virtual thread only, never its carrier
var response = http.send(request, BodyHandlers.ofString());
return new Page(url, response.statusCode(), response.body().length());
}
}
send() is the blocking call. That used to be the line you were not allowed to
write in a scalable service.
Note what is absent: no callbacks, no thenCompose, no scheduler to configure.
The concurrency lives in the executor, and fetch is a method you could have
written in 2005.
No speedup at low load. Under light traffic virtual threads are a wash and can be marginally slower, since mounting and unmounting is not free. The gain appears at the point where a fixed pool would have started queueing.
Nothing for CPU-bound work. A virtual thread that never blocks never unmounts, so it occupies a carrier exactly like a platform thread would, plus bookkeeping. Compute-heavy stages still belong on a bounded pool.
Pinning, now a much smaller list. A pinned virtual thread cannot unmount, so it
holds its carrier through the whole wait. Until JDK 24 the big offender was
synchronized, because monitors were tracked against the carrier; JEP 491 fixed
that by making monitors virtual-thread-aware. What still pins is native frames
(JNI and the FFM API), class loading and initializers, and file I/O on Linux,
where the JDK has no io_uring backend. Find these with the
jdk.VirtualThreadPinned JFR event rather than by reading code.
Thread-locals get expensive. A ThreadLocal cache was a reasonable trade when
threads were few and long-lived. With a thread per request it is just allocation,
and inheritable thread-locals copy the whole map into every child.
Some libraries assume thread identity is stable. Anything keying state by thread, including MDC-style logging context, transaction managers and object pools, behaves differently when threads are ephemeral and a task can resume on a different carrier. Worth an explicit test rather than an assumption.
The summary is narrow and specific: blocking became an implementation detail instead of an architectural decision. You no longer choose between code you can read and code that scales, which was the real tax reactive programming charged.
The awkward part is what that unlocks. Your thread pool was doing two jobs, running your code and limiting how much work was in flight, and virtual threads only replaced the first one. Start a thread per request and the pool that used to cap you at 200 concurrent database calls is simply gone, while the database still answers 20 at a time.
Part 2 takes up the first half of that problem: now that virtual threads are
free, which executor should actually run a given piece of work, and when a fixed
pool or a ForkJoinPool is still the right answer.
Sources worth reading directly: JEP 444: Virtual Threads, JEP 491: Synchronize Virtual Threads without Pinning, and Oracle’s core libraries guide.
The mounting and unmounting section draws on Modern Concurrency in Java: Virtual Threads, Structured Concurrency, and Beyond (O’Reilly, 2025), which is the best long-form treatment of this material I have found, and the source of the virtual memory analogy.