writing
Virtual Threads in Java Part 1 of 3
  1. 01 Java Virtual Threads: The Thread Stopped Being Expensive

Java Virtual Threads: The Thread Stopped Being Expensive

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.

What concurrency actually buys you

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.

Sequential requests versus concurrent requests, showing that most of a request is waiting on I/O working (CPU) waiting on I/O SEQUENTIAL / ONE THREAD request 1 request 2 request 3 CONCURRENT / THREE THREADS same three requests, roughly the time of one

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.

Why platform threads forced us to pool

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.

A fixed thread pool acting as a hard ceiling on in-flight requests REQUESTS queue grows FIXED POOL platform thread, blocked in read() platform thread, blocked in read() platform thread, blocked in read() 200 threads, ~1 MB stack each scheduled by the OS kernel database hard ceiling on in-flight work

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.

What a virtual thread is

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.

How the JVM mounts and unmounts a virtual thread

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.

Timeline of one virtual thread through a blocking call, showing frames moving between the carrier stack and the heap while the OS thread stays busy throughout ① blocking call your code + JDK library ② I/O ready OS kernel + JVM scheduler VIRTUAL THREAD running parked, costs a heap object running, next line CARRIER STACK holds its frames borrowed by another virtual thread holds its frames HEAP frames parked here OS KERNEL one platform thread, busy the whole time, unaware any of this happened copy out copy back Frames move between the carrier stack and the heap. The carrier is free for the entire wait, and may be a different carrier on the way back.

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.

Many virtual threads scheduled onto a small fixed pool of carrier threads backed by OS threads VIRTUAL THREADS ... 1,000,000 of them, stacks on the heap CARRIERS carrier-0 carrier-1 carrier-2 carrier-3 ForkJoinPool, FIFO parallelism = #cores OS THREADS kernel thread kernel thread kernel thread kernel thread the JVM may briefly add carriers past the core count to compensate for certain blocking calls, bounded by jdk.virtualThreadScheduler.maxPoolSize

Platform versus virtual, side by side

Platform threadVirtual thread
Backed byAn OS threadA heap object, scheduled onto a carrier
Created bySystem callJVM, in user space
StackReserved by the OS, ~1 MBOn the GC heap, a few hundred bytes to start, grows as needed
Scheduled byOS kernelJVM’s ForkJoinPool scheduler
Cost of blockingThe whole OS thread is idleA heap object is idle, the carrier moves on
Practical countThousandsMillions

Proving the scale claim

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.

A real example

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.

One virtual thread per URL, all parked on network I/O at the same time, then joined executor.submit() × 3 vthread: jeps/444 vthread: jeps/491 vthread: jeps/425 parked on network parked on network parked on network three threads waiting, zero carriers held future.get()

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.

What you give up

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.

Where this goes next

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.

filed under #java#jvm#concurrency
← previous