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.
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
Semaphoreto limit concurrency. - Pinning inside
synchronizedblocks the carrier — the one real Java 21 gotcha. StructuredTaskScopeties task lifetimes to a lexical scope: automatic cancellation and error propagation.
The mechanism
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
// 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=trueThe try-with-resources form works because ExecutorService became AutoCloseable in Java 19, and
close() performs shutdown() plus awaitTermination().
Pinning
// 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.
-Djdk.tracePinnedThreads=full # prints a stack trace whenever a thread pinsJFR 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.
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.
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 threads | Virtual threads | |
|---|---|---|
| Cost each | ~1MB stack, ~50µs to create | ~a few hundred bytes, negligible |
| Practical count | Hundreds to a few thousand | Millions |
| Pooling | Essential | Counterproductive |
| Blocking I/O | Wastes a thread | Unmounts, costs nothing |
| CPU-bound work | Use a sized pool | No benefit — still use a pool |
| Debugging | Normal | Normal, 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?
What is pinning and why does it matter?
Do virtual threads replace reactive programming?
Related tutorials
- CountDownLatch, Semaphore, CyclicBarrier and PhaserThe coordination primitives in java.util.concurrent, when a latch beats a barrier, using a semaphore as a bulkhead, and the AbstractQueuedSynchronizer that all of them are built on.
- Deadlock, Livelock and StarvationThe four conditions every deadlock needs and how breaking one prevents it, reading a deadlock out of a thread dump, lock ordering and tryLock, and the pool-starvation deadlock with no locks at all.
- CompletableFuture and Async CompositionComposing async work without blocking: thenApply versus thenCompose, which thread runs each stage, combining with allOf and anyOf, timeouts, and how exceptions propagate through a chain.
- ExecutorService and Thread-Pool SizingHow ThreadPoolExecutor decides whether to queue or grow, why newFixedThreadPool can exhaust the heap, sizing pools from measurements with Little law, and shutting down without losing work.