Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Virtual Threads and Structured Concurrency

How a virtual thread mounts and unmounts from a carrier, why pinning on synchronized still matters, why pooling virtual threads is wrong, and what StructuredTaskScope adds over raw futures.

Advanced6 min readUpdated
On this page

Virtual threads are the largest change to Java concurrency since Java 5, and the interview question is rarely "what are they" — it is "what changes in how you write code, and what still bites you".

Key Takeaways

  • A virtual thread is a JVM object, not an OS thread. A few hundred bytes; millions are routine.
  • It mounts onto a carrier (platform) thread to run and unmounts when it blocks, freeing the carrier.
  • Do not pool them. One per task is the model; use a Semaphore to limit concurrency.
  • Pinning inside synchronized blocks the carrier — the one real Java 21 gotcha.
  • StructuredTaskScope ties task lifetimes to a lexical scope: automatic cancellation and error propagation.

The mechanism

When a virtual thread blocks, its stack is copied to the heap and the carrier is released. It remounts — possibly on a different carrier — when the I/O completes.

The JDK's blocking operations — socket reads, Thread.sleep, BlockingQueue.take, JDBC on a modern driver — were rewritten to detect a virtual thread and unmount instead of blocking the OS thread. That single change is what makes ordinary blocking code scale.

The scheduler is a dedicated ForkJoinPool sized to availableProcessors(), configurable with -Djdk.virtualThreadScheduler.parallelism. It is separate from the common pool used by parallel streams.

Using them

the executor is the whole API change
// 10,000 concurrent HTTP calls. On platform threads this needs 10GB of stack
// reservation and would fail; here it costs a few megabytes.
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<Response>> futures = urls.stream()
            .map(url -> exec.submit(() -> httpClient.send(request(url), ofString())))
            .toList();
    // close() waits for every task
}
 
// One-off
Thread.startVirtualThread(() -> handle(request));
 
// With a name, for thread dumps
Thread.ofVirtual().name("order-", 1).start(task);
 
// In Spring Boot 3.2+
// spring.threads.virtual.enabled=true

The try-with-resources form works because ExecutorService became AutoCloseable in Java 19, and close() performs shutdown() plus awaitTermination().

Pinning

the one real gotcha on Java 21
// BAD: the virtual thread cannot unmount inside a synchronized block,
// so it pins its carrier for the entire duration of the HTTP call.
public synchronized Response fetch(String id) {
    return httpClient.send(request(id), ofString());
}
 
// GOOD: a ReentrantLock lets the virtual thread unmount while blocked.
private final ReentrantLock lock = new ReentrantLock();
 
public Response fetch(String id) {
    lock.lock();
    try {
        return httpClient.send(request(id), ofString());
    } finally {
        lock.unlock();
    }
}

On Java 21, a virtual thread inside a synchronized block or a native frame cannot unmount. With only a handful of carriers, a few dozen pinned threads exhaust the pool and throughput collapses to platform-thread levels — or worse, since the scheduler cannot compensate.

find it
-Djdk.tracePinnedThreads=full    # prints a stack trace whenever a thread pins

JFR also emits a jdk.VirtualThreadPinned event. In practice the usual sources are old JDBC drivers, synchronized connection pools, and legacy library internals.

JEP 491 in Java 24 largely removed this limitation by allowing unmounting inside synchronized blocks. Knowing both the Java 21 behaviour and that it was fixed is a good answer, because a great deal of production code is still on 21.

Thread-locals

Thread-locals still work, and their cost model inverts. With 200 pooled platform threads, a thread-local holds at most 200 values. With a million virtual threads, it holds up to a million — and a large context object per thread is suddenly a memory problem.

the immutable replacement
private static final ScopedValue<TenantId> TENANT = ScopedValue.newInstance();
 
ScopedValue.where(TENANT, tenantId).run(() -> {
    // Visible to this thread and to any structured child, immutable,
    // and automatically unbound when the block exits.
    process(order);
});

ScopedValue (preview in 21, finalised later) is the designed successor: immutable, inherited by structured children, and scoped lexically so it cannot leak the way a pooled thread's ThreadLocal does. That leak is a real production bug — see The seven classic memory leaks.

Structured concurrency

Unstructured concurrency lets a task outlive the method that started it. Structured concurrency ties lifetimes to a lexical scope, the same way a block ties the lifetime of a local variable.

fan out, join, propagate
public Order buildOrder(String orderId) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
 
        Subtask<Customer>  customer  = scope.fork(() -> crm.fetch(orderId));
        Subtask<List<Item>> items    = scope.fork(() -> catalogue.itemsFor(orderId));
        Subtask<Payment>   payment   = scope.fork(() -> payments.status(orderId));
 
        scope.join();                 // wait for all three
        scope.throwIfFailed();        // rethrow the first failure
 
        return new Order(customer.get(), items.get(), payment.get());
    }
    // On exit — normal, exceptional or via interrupt — every subtask is
    // guaranteed to be finished or cancelled. Nothing outlives this method.
}

Four properties you get for free, all of which are manual work with raw futures:

Automatic cancellation. If crm.fetch fails, ShutdownOnFailure interrupts the other two immediately. No wasted work, no orphaned tasks.

Error propagation. throwIfFailed() rethrows the first exception with the subtask's stack trace attached, rather than a CompletionException wrapping something.

No leaks. The scope cannot be exited while a subtask is running. A method that starts work always finishes or cancels it.

Readable stack traces. The subtask's stack shows its parent, so a thread dump reflects the actual call structure rather than a flat list of pool workers.

ShutdownOnSuccess is the complementary policy: the first successful result wins and the rest are cancelled — hedged requests to a primary and a replica.

What actually changes

Platform threadsVirtual threads
Cost each~1MB stack, ~50µs to create~a few hundred bytes, negligible
Practical countHundreds to a few thousandMillions
PoolingEssentialCounterproductive
Blocking I/OWastes a threadUnmounts, costs nothing
CPU-bound workUse a sized poolNo benefit — still use a pool
DebuggingNormalNormal, unlike reactive

That fifth row is the caveat to volunteer. Virtual threads help when threads spend their time waiting. For CPU-bound work the machine still has the same number of cores, and a million virtual threads competing for eight carriers is worse than a pool of eight. Virtual threads are a concurrency mechanism, not a parallelism one.

What gets asked

"What are virtual threads and when do they help?" — answer with the mount/unmount mechanism and the I/O-bound qualifier. Then: should you pool them (no, use a semaphore); what is pinning (and that Java 24 fixed most of it); and do they replace reactive (mostly, for request-per-thread servers). Being precise about "they help with blocking I/O, not with CPU-bound work" is the detail that signals you have actually measured this rather than read the release notes.

Frequently Asked Questions

Should I pool virtual threads?
No. Pooling exists to amortise the cost of creating an expensive resource, and a virtual thread costs a few hundred bytes and no system call. Executors.newVirtualThreadPerTaskExecutor creates one per task and discards it, which is the intended model. If you need to limit concurrency — because a downstream dependency cannot take more — use a semaphore, which expresses that intent directly instead of conflating it with thread reuse.
What is pinning and why does it matter?
A virtual thread normally unmounts from its carrier when it blocks, freeing the carrier for other work. Inside a synchronized block on Java 21 it cannot unmount, so it pins the carrier for the duration of the block. If many virtual threads pin carriers while doing I/O, the small carrier pool is exhausted and throughput collapses. Replacing hot synchronized blocks with ReentrantLock avoids it; Java 24 removed most pinning.
Do virtual threads replace reactive programming?
For most request-per-thread server code, largely yes — the main reason to write reactive chains was avoiding one OS thread per in-flight request, and virtual threads remove that constraint while keeping ordinary blocking code, readable stack traces and working debuggers. Reactive still wins where you need genuine backpressure across a streaming pipeline, or a pull-based model over an unbounded source.

Related tutorials