PrepZone Logo
PrepZone

Virtual Threads

Millions of concurrent tasks on a handful of carrier threads, and the pinning trap to avoid.

Why this matters

  • The thread-per-request model is the simplest way to write server code, and virtual threads make it scale again — you no longer have to choose between readable code and throughput.
  • Asynchronous and reactive code exists largely to avoid blocking OS threads. Remove that constraint and much of the complexity is no longer justified.
  • The failure mode, pinning, is specific and avoidable, and knowing it is the difference between virtual threads helping and quietly not helping.

The problem with platform threads

Java
// Each task holds a real OS thread for its entire duration, including the waiting
ExecutorService pool = Executors.newFixedThreadPool(200);
for (int i = 0; i < 10_000; i++) {
    pool.submit(() -> {
        Response response = httpClient.send(request);   // ~100 ms of doing nothing
        return process(response);
    });
}

A platform thread reserves a stack — typically around a megabyte of address space — and its creation and context switching go through the kernel. A few thousand is the practical ceiling, which is why pools exist. And when a pooled thread blocks on I/O, it is simply unavailable: 200 threads each waiting 100 ms means at most 2,000 requests per second regardless of how little CPU the work needs.

Virtual threads

Java
// One virtual thread per task — a million is reasonable
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000_000; i++) {
        executor.submit(() -> {
            Response response = httpClient.send(request);   // blocks the virtual thread only
            return process(response);
        });
    }
}   // close() waits for every task — the executor is an AutoCloseable
Platform thread
1 : 1 with an OS thread~1 MB stack each
Blocking wastes the threadOS thread sits idle
Thousands maximum
Virtual thread
Many : few carrier threadsGrows on demand
Blocking unmounts itCarrier runs other work
Millions are practical

Virtual threads help throughput for input/output-bound work. They do not make processor-bound code any faster.

A platform thread maps to one operating system thread, so a few thousand is the practical ceiling. Virtual threads are managed by the JVM and unmount while blocked, so millions are fine.
Java
// Other ways to create one
Thread.startVirtualThread(() -> doWork());

Thread virtual = Thread.ofVirtual().name("worker-1").unstarted(runnable);
virtual.start();

Thread platform = Thread.ofPlatform().daemon().start(runnable);   // the same builder API

System.out.println(Thread.currentThread().isVirtual());

How it works

Virtual thread runsMounted on a carrier thread
UnmountsStack parked on the heap
Carrier runs another virtual thread
Remounts and continues

A synchronized block can pin a virtual thread to its carrier. Prefer ReentrantLock in code that runs on virtual threads.

Blocking is what triggers the unmount. The carrier thread is freed for other virtual threads, which is how a handful of carriers serve enormous numbers of tasks.

The mechanism in four steps

  • A virtual thread runs on a carrier thread, taken from a dedicated ForkJoinPool sized to the number of cores.
  • When it hits a blocking operation that the JDK has adapted — socket I/O, sleep, a lock, a BlockingQueue — the JVM unmounts it, copying its stack to the heap and freeing the carrier.
  • The carrier immediately picks up another runnable virtual thread. No OS thread is idle.
  • When the operation completes, the virtual thread is remounted, possibly on a different carrier, and continues from exactly where it stopped.

This is why blocking is cheap: the cost of blocking a virtual thread is a stack copy on the heap, not a parked OS thread.

AspectPlatform threadVirtual thread
Backed byOne OS threadA heap-allocated stack, mounted on a carrier when running
StackFixed, around 1 MB reservedGrows and shrinks on the heap, starts tiny
Practical countThousandsMillions
Creation costA kernel call — pool themRoughly an object allocation — never pool them
Cost of blockingAn OS thread sits idleNear zero — the carrier is released
SchedulingPre-emptive, by the OSCooperative, by the JVM at blocking points
Good forCPU-bound workI/O-bound work
Priorities and thread groupsSupportedIgnored; always daemon, always normal priority
  • Backed by

    Platform threadOne OS thread
    Virtual threadA heap-allocated stack, mounted on a carrier when running
  • Stack

    Platform threadFixed, around 1 MB reserved
    Virtual threadGrows and shrinks on the heap, starts tiny
  • Practical count

    Platform threadThousands
    Virtual threadMillions
  • Creation cost

    Platform threadA kernel call — pool them
    Virtual threadRoughly an object allocation — never pool them
  • Cost of blocking

    Platform threadAn OS thread sits idle
    Virtual threadNear zero — the carrier is released
  • Scheduling

    Platform threadPre-emptive, by the OS
    Virtual threadCooperative, by the JVM at blocking points
  • Good for

    Platform threadCPU-bound work
    Virtual threadI/O-bound work
  • Priorities and thread groups

    Platform threadSupported
    Virtual threadIgnored; always daemon, always normal priority

Virtual threads do not make CPU-bound work faster — there are still only as many cores as there are cores.

Stop pooling

Java
// Wrong — a pool of virtual threads caps concurrency for no benefit
ExecutorService wrong = Executors.newFixedThreadPool(200, Thread.ofVirtual().factory());

// Right — one virtual thread per task
ExecutorService right = Executors.newVirtualThreadPerTaskExecutor();
Java
// Limit the scarce resource, not the threads
private final Semaphore dbPermits = new Semaphore(20);

void handle(Request request) throws InterruptedException {
    dbPermits.acquire();
    try {
        database.query(request);
    } finally {
        dbPermits.release();
    }
}

Pinning — the one real trap

A virtual thread cannot unmount in two situations, so it holds its carrier while blocked.

Java
// 1. Inside a synchronized block that blocks (fixed in Java 24; still relevant on 21)
synchronized (lock) {
    Response response = httpClient.send(request);     // pinned: the carrier is held
}

// 2. Inside a native method or a foreign function call
Java
// The fix on Java 21: use a ReentrantLock, which is virtual-thread aware
private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    Response response = httpClient.send(request);      // unmounts correctly
} finally {
    lock.unlock();
}
Java
// Also replace ThreadLocal caching patterns — with a million threads, a million copies
// Scoped values (a preview API in 21) are the intended replacement for context propagation.
// Until they are final, prefer passing context explicitly as a parameter.

Structured concurrency

The companion feature, still a preview API in Java 21, gives a scope a defined lifetime so related tasks succeed or fail together.

Java
// Preview in Java 21 — enable with --enable-preview
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Supplier<User> user = scope.fork(() -> loadUser(id));
    Supplier<List<Order>> orders = scope.fork(() -> loadOrders(id));

    scope.join();                   // wait for both
    scope.throwIfFailed();          // if either failed, the other was cancelled

    return new Dashboard(user.get(), orders.get());
}

What this means for application design

Java
// On Java 21, straightforward blocking code on a virtual thread is often
// clearer than the equivalent CompletableFuture chain — and performs comparably.
User user = userService.load(id);                     // blocks, cheaply
List<Order> orders = orderService.recent(user);        // blocks, cheaply
return new Dashboard(user, orders);

What changes, and what does not

  • Thread-per-request is viable again. A servlet or Spring MVC controller on a virtual thread scales like a reactive one for I/O-bound work.
  • Debugging gets better. A virtual thread has an ordinary stack trace, so a thread dump shows the whole logical operation. A reactive chain does not.
  • CPU-bound work is unaffected. Use a bounded platform-thread pool for computation.
  • Connection pools and rate limits still matter. Removing the thread ceiling exposes whatever the next bottleneck is, usually the database.
  • Existing blocking libraries mostly just work, because the JDK adapted the blocking points underneath them.

Common misreadings

  • "Virtual threads make everything faster." They improve throughput for I/O-bound work. CPU-bound work is bounded by cores.
  • "Pool them like platform threads." Never. Create one per task.
  • "A virtual thread is a lightweight OS thread." It is managed entirely by the JVM; the OS does not know it exists.
  • "Blocking is still bad." Blocking a virtual thread is the intended way to use it.
  • "synchronized is fine." On Java 21 it pins the carrier during blocking I/O. Prefer ReentrantLock.
  • "ThreadLocal works the same." It works, but a million threads means a million copies.
  • "Structured concurrency is production-ready in 21." It is a preview API whose shape is still changing.
  • "You must rewrite your code." Most blocking code runs unchanged; the audit is for pinning and downstream limits.

Quick recall

Everything you need if you only revisit this box.

  • A virtual thread is scheduled by the JVM onto a carrier thread from a core-sized ForkJoinPool; it unmounts at blocking points and remounts afterwards.
  • Millions are practical: the stack lives on the heap and grows on demand.
  • Never pool virtual threads. Use Executors.newVirtualThreadPerTaskExecutor(), and a Semaphore to limit a scarce resource.
  • They help I/O-bound work. CPU-bound work still needs a bounded platform pool.
  • Pinning holds the carrier: caused by blocking inside synchronized (on 21) or in native code. Fix with ReentrantLock; detect with -Djdk.tracePinnedThreads=full.
  • Virtual threads are always daemon, ignore priorities, and cannot be in a thread group.
  • ThreadLocal still works but scales badly; scoped values are the eventual replacement.
  • Structured concurrency (StructuredTaskScope) is a preview API in 21 — understand it, do not ship it yet.

Test yourself

Answer these before moving on — recall is what makes it stick.