Java can run a hundred thousand blocking tasks on a handful of OS threads. Learn how virtual threads mount and unmount, what still pins them, why ScopedValue replaces ThreadLocal, and how StructuredTaskScope cancels work that fails.
A virtual thread is a java.lang.Thread that costs about as much as an ordinary object. You can start a hundred thousand of them, let each one block on a slow call, and the JVM runs them all on a few operating system threads. Java 21 made them final, and they change how you write servers: one plain, blocking thread per request is fine again.
This post covers virtual threads and how they mount on carrier threads, when they help, and what still pins them. Then it covers ScopedValue, which replaces most uses of ThreadLocal, and StructuredTaskScope, which is still a preview feature in Java 25. Every program below was run on Java 25, and its output is pasted from the run. To run one yourself, save it as Main.java and run java Main.java.
Platform threads are expensive
A classic Java Thread is a platform thread: a thin Java wrapper around an operating system thread. The OS reserves a stack for each one (the JVM’s default on Linux x64 is 1 MB, which we checked with -XX:+PrintFlagsFinal), and creating one is a system call. So a server can afford thousands of them, not millions.
A virtual thread is a Thread too, with the same methods. The builders from the part on threads have a virtual twin:
void main() throws InterruptedException {
Thread platform = Thread.ofPlatform().start(() -> IO.println("hello from a platform thread"));
platform.join();
Thread first = Thread.ofVirtual().start(() -> IO.println("hello from a virtual thread"));
first.join();
Thread second = Thread.startVirtualThread(() -> IO.println("and from another one"));
second.join();
IO.println("platform.isVirtual() = " + platform.isVirtual());
IO.println("first.isVirtual() = " + first.isVirtual());
IO.println("second.isVirtual() = " + second.isVirtual());
IO.println("main is virtual: " + Thread.currentThread().isVirtual());
}
It prints:
hello from a platform thread
hello from a virtual thread
and from another one
platform.isVirtual() = false
first.isVirtual() = true
second.isVirtual() = true
main is virtual: false
Thread.ofVirtual().start(...) and Thread.startVirtualThread(...) do the same thing. isVirtual() tells you which kind you’re on, and main always runs on a platform thread. Everything else is the Thread API you already know: join, interrupt, getState.
We compiled the program with javac --release 20 as well. It fails with ofVirtual() is a preview API and is disabled by default. With --release 21 it compiles, so virtual threads are final since Java 21.
A hundred thousand blocking tasks
Most code doesn’t start threads one at a time. Executors.newVirtualThreadPerTaskExecutor() starts a new virtual thread for every task you submit. Here, 100,000 tasks each sleep for a second, standing in for a slow network call:
void main() {
var completed = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // stands in for a slow network call
completed.incrementAndGet();
return null;
});
}
} // close() waits for every task to finish
IO.println("tasks completed: " + completed.get());
}
It prints:
tasks completed: 100000
An executor’s close() waits for every submitted task, so the count is read only after all 100,000 have run. The lambda returns null so it’s a Callable, which is allowed to throw the InterruptedException that sleep declares.
How long did it take? We timed it. These timings vary from run to run and machine to machine, so treat them as a rough picture:
$ time java Main.java
tasks completed: 100000
real 0m3.823s
That includes compiling the file. Then we changed one line to start a platform thread per task, Executors.newThreadPerTaskExecutor(Thread.ofPlatform().factory()). It also printed 100000, but it took 54 seconds. Creating 100,000 operating system threads is slow. The virtual version spent most of its time with all 100,000 tasks asleep at once.
Mounting and unmounting
A virtual thread only needs an OS thread while it’s running Java code, and it gives that thread back whenever it blocks. The OS threads that run virtual threads are called carrier threads. Here’s a simplified picture with two carriers and four virtual threads:
Simplified: two carrier threads share four virtual threads. A virtual thread that blocks gives its carrier back, and its stack waits on the heap. When it can run again, it mounts on whichever carrier is free. The real scheduler usually has one carrier per CPU core and many more virtual threads.
Here are those steps in words, in case the animation doesn’t play for you:
- Four virtual threads, VT1 to VT4, are ready to run. Carrier 1 and carrier 2 are idle.
- VT1 mounts on carrier 1 and VT2 mounts on carrier 2. Both run Java code. VT3 and VT4 wait.
- VT1 starts a network read that has to wait. It unmounts: its stack frames are saved on the heap, and carrier 1 is free.
- Carrier 1 doesn’t sit idle. VT3 mounts on it and runs. VT4 keeps waiting.
- VT1’s read completes. VT1 is ready to run again, so it joins the queue for a carrier.
- VT2 finishes and carrier 2 comes free. VT1 mounts on carrier 2 and carries on from where it stopped, on a different carrier from the one it started on.
Explain it like I’m ten
A town has a few big delivery trucks. Those are platform threads. Each truck is expensive, so the town can only afford a handful.
Virtual threads are thousands of bike couriers. A courier only hops onto a truck while a parcel is actually moving. When a courier has to wait at a door for someone to sign, they hop off, and another courier gets on the truck. When the door opens, the first courier hops onto whichever truck comes by next.
So a handful of trucks keeps thousands of couriers busy, as long as most of the job is waiting at doors.
The precise version
A virtual thread is a Thread object whose stack isn’t a fixed block of OS memory. The JVM runs it by mounting it on a carrier thread, and the carriers are platform threads in a ForkJoinPool that the JDK owns. By default there’s one carrier per available processor.
When a virtual thread blocks inside the JDK, for example in Thread.sleep, a socket read, CountDownLatch.await or a lock, the JDK unmounts it. It copies the thread’s stack frames into objects on the heap and releases the carrier. When the thing it waited for happens, the thread is handed back to the scheduler and mounted on any free carrier. Your code sees none of this. The call simply returns later. That’s why the 100,000 sleeping tasks were cheap: each one was a small heap object, not a parked OS thread.
Where the analogy breaks: a courier decides to hop off. A virtual thread doesn’t. The JDK unmounts it, and only at blocking points it knows about. If the stack can’t be moved, the thread stays on the truck while it waits. That’s called pinning, and it gets its own section below. Also, a truck carries many parcels, but a carrier runs exactly one virtual thread at a time.
When virtual threads help, and when they don’t
Virtual threads help code that spends most of its time waiting. A web server that handles each request by calling a database and two other services, one blocking call after another, is the classic case. You can write it in the simple thread-per-request style and still serve a very large number of requests at once, because a waiting request holds no OS thread.
They don’t make computing faster. A task that spends its time adding numbers never blocks, so it never unmounts, and the number of carriers is still the number of cores. Ten thousand CPU-bound virtual threads get no more CPU than a pool of platform threads the size of your machine.
Two habits from platform threads are wrong for virtual threads:
- Don’t pool them. A pool exists to reuse something expensive. Virtual threads are cheap, so create one per task and let it end.
- Don’t limit concurrency with a small pool. If a downstream service can take only 10 calls at once, limit the calls with a
Semaphoreinstead.
void main() {
var permits = new Semaphore(10);
var running = new AtomicInteger();
var peak = new AtomicInteger();
var completed = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000; i++) {
executor.submit(() -> {
permits.acquire(); // waits here while 10 tasks hold a permit
try {
peak.accumulateAndGet(running.incrementAndGet(), Math::max);
Thread.sleep(Duration.ofMillis(10)); // the call to the limited service
completed.incrementAndGet();
} finally {
running.decrementAndGet();
permits.release();
}
return null;
});
}
}
IO.println("tasks completed: " + completed.get());
IO.println("never more than 10 at once: " + (peak.get() <= 10));
}
It prints:
tasks completed: 1000
never more than 10 at once: true
All 1,000 tasks got a virtual thread straight away, but acquire() let only 10 past at a time. The other 990 waited cheaply, unmounted. The release() sits in finally, so a failing call can’t leak a permit. The part on java.util.concurrent covers Semaphore and the executors.
Pinning: when a virtual thread can’t let go
A virtual thread is pinned when it blocks but can’t unmount, so it holds its carrier the whole time it waits. With enough pinned threads, every carrier is stuck and nothing else runs.
In Java 21, blocking inside synchronized pinned the thread. Java 24 changed that (JEP 491), so in Java 25 a virtual thread that waits while holding a monitor unmounts like any other. Some cases still pin. The program below tests two of them. It asks for a single carrier with the system property jdk.virtualThreadScheduler.parallelism, which is the same as passing -D on the command line. With one carrier, a pinned thread blocks every other virtual thread:
static final CountDownLatch configReleased = new CountDownLatch(1);
static class Config {
static final String NAME = load();
static String load() {
awaitQuietly(configReleased); // blocks inside a static initializer
return "loaded";
}
}
final Object lock = new Object();
void main() throws InterruptedException {
// One carrier thread for every virtual thread. Set it before the first one starts.
System.setProperty("jdk.virtualThreadScheduler.parallelism", "1");
var lockReleased = new CountDownLatch(1);
Thread inLock = Thread.ofVirtual().start(() -> {
synchronized (lock) {
awaitQuietly(lockReleased); // blocks while holding a monitor
}
});
IO.println("blocked in synchronized, others run: " + othersRun(lockReleased));
inLock.join();
Thread inInit = Thread.ofVirtual().start(() -> Config.NAME.length());
IO.println("blocked in a static initializer, others run: " + othersRun(configReleased));
inInit.join();
}
// Starts a second virtual thread that releases the first. Reports whether it got to run.
boolean othersRun(CountDownLatch release) throws InterruptedException {
Thread.sleep(Duration.ofMillis(200)); // give the first thread time to block
Thread other = Thread.ofVirtual().start(release::countDown);
boolean ran = other.join(Duration.ofSeconds(1));
release.countDown(); // if it couldn't run, release the first thread from here
other.join();
return ran;
}
static void awaitQuietly(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
}
It prints:
blocked in synchronized, others run: true
blocked in a static initializer, others run: false
In the first test, a virtual thread waits while it holds lock. It unmounts, the single carrier runs the second thread, and that thread releases the first. In the second test, the thread waits inside Config‘s static initializer. It stays pinned, the second thread never gets the carrier, and join gives up after a second. We ran it 20 times and got the same two lines every time.
To see pinning in a real program, record it with Java Flight Recorder. The JDK has a jdk.VirtualThreadPinned event for this. We ran the same program with a recording and printed the events (trimmed; the duration varies):
$ java -XX:StartFlightRecording:filename=pinned.jfr Main.java
$ jfr print --events jdk.VirtualThreadPinned pinned.jfr
jdk.VirtualThreadPinned {
duration = 1.20 s
blockingOperation = "LockSupport.park"
pinnedReason = "VM call to Main$Config.<clinit> on stack"
...
}
There was exactly one event, for the static initializer. The synchronized wait didn’t record one. We also tried a native call: a C function, qsort, called through the foreign function API, with a comparator that calls back into Java and sleeps. That pinned too, with the reason "Native or VM frame on stack".
One surprise: older articles tell you to run with -Djdk.tracePinnedThreads=full. On Java 25 it does nothing. We passed it to the same program and nothing extra printed, even for the pinned thread. Use the JFR event instead.
ThreadLocal and its problems
A ThreadLocal gives each thread its own copy of a variable. Frameworks have long used it to carry things like the current user through a request without passing them to every method. It has three problems, and virtual threads make all three worse.
It’s mutable, so any code on the thread can call set and change the value under everyone else. It lives as long as the thread, unless someone remembers remove(). And each thread keeps its own copy, so a million virtual threads mean a million copies. The lifetime problem is the easiest one to show:
static final ThreadLocal<String> USER = new ThreadLocal<>();
void main() throws Exception {
try (ExecutorService pool = Executors.newFixedThreadPool(1)) {
pool.submit(() -> {
USER.set("ana");
IO.println("request 1 runs as " + USER.get());
// forgot USER.remove()
}).get();
pool.submit(() -> IO.println("request 2 runs as " + USER.get())).get();
}
}
It prints:
request 1 runs as ana
request 2 runs as ana
Both requests ran on the pool’s one thread. The first set the user and never removed it, so the second request runs as Ana. In a real server that’s one user seeing another user’s data.
ScopedValue: a value for the length of a call
A ScopedValue is bound to a value for the duration of one call, and every method that call reaches can read it. When the call returns, the binding is gone. It became final in Java 25. We checked: javac --release 24 rejects it as a preview API.
static final ScopedValue<String> USER = ScopedValue.newInstance();
void main() {
ScopedValue.where(USER, "ana").run(() -> handleRequest());
IO.println("after run, bound: " + USER.isBound());
ScopedValue.where(USER, "bo").run(() -> {
audit("outer");
ScopedValue.where(USER, "admin").run(() -> audit("inner"));
audit("outer again");
});
}
void handleRequest() {
IO.println("handling, bound: " + USER.isBound());
loadOrders();
}
void loadOrders() {
audit("loading orders"); // three calls deep, no parameter passed
}
void audit(String action) {
IO.println(USER.get() + ": " + action);
}
It prints:
handling, bound: true
ana: loading orders
after run, bound: false
bo: outer
admin: inner
bo: outer again
ScopedValue.where(USER, "ana").run(...) binds USER while the lambda runs. audit reads it three calls down, and nobody passed it along. After run returns, isBound() is false.
There’s no set method. The only way to change the value is to bind it again for a smaller call, as the "admin" binding does. When that inner call returns, the old value "bo" is back. So the bound value can’t leak into a later request, and code you call can’t change it under you.
Reading a scoped value that isn’t bound throws:
static final ScopedValue<String> USER = ScopedValue.newInstance();
void main() {
IO.println("bound: " + USER.isBound());
IO.println("with a fallback: " + USER.orElse("guest"));
IO.println("user: " + USER.get());
}
It prints, then stops:
bound: false
with a fallback: guest
Exception in thread "main" java.util.NoSuchElementException: ScopedValue not bound
Check with isBound(), or use orElse when there’s a sensible default.
Structured concurrency (preview)
Structured concurrency means that tasks started together finish together. If you split a request into subtasks, none of them outlives the request, and a failure in one stops the others. Java’s API for it, StructuredTaskScope, is a preview feature in Java 25. It may still change before it’s final, and it has changed already. Many articles show new StructuredTaskScope.ShutdownOnFailure(). We checked with javac --release: that class exists in Java 21 and 24, and on 25 the same code fails with cannot find symbol. The API doesn’t compile without a flag, so you run these programs with java --enable-preview Main.java.
The problem with an unstructured fan-out
An ExecutorService lets you start two calls in parallel, but nothing ties them together. Here, one call is slow and the other fails:
void main() throws InterruptedException {
var neverOpens = new CountDownLatch(1);
var executor = Executors.newVirtualThreadPerTaskExecutor();
Future<String> user = executor.submit(() -> {
neverOpens.await(); // a slow call that is still going
return "ana";
});
Future<Integer> orders = executor.submit(() -> {
throw new IllegalStateException("orders service is down");
});
try {
int count = orders.get(); // ask for the failing one first
IO.println(user.get() + " has " + count + " orders");
} catch (ExecutionException e) {
IO.println("request failed: " + e.getCause().getMessage());
}
IO.println("user task still running: " + !user.isDone());
executor.shutdownNow(); // interrupts it; close() here would wait forever
IO.println("stopped after shutdownNow: " + executor.awaitTermination(1, TimeUnit.SECONDS));
}
It prints:
request failed: orders service is down
user task still running: true
stopped after shutdownNow: true
The request failed, but the user task kept running. Nothing told it to stop, so it leaked until we shut the executor down by hand. Notice the comment on orders.get(), too. Our first version called user.get() first, and it hung: main waited on the slow call and never found out the other one had already failed.
StructuredTaskScope: fork, then join
A StructuredTaskScope is opened in a try-with-resources block, and every subtask forked inside it has to finish before the block ends:
import java.util.concurrent.StructuredTaskScope.Subtask;
record Page(String user, int orders) {}
void main() throws InterruptedException {
try (var scope = StructuredTaskScope.open()) {
Subtask<String> user = scope.fork(() -> findUser(42));
Subtask<Integer> orders = scope.fork(() -> countOrders(42));
scope.join(); // waits for both
IO.println(new Page(user.get(), orders.get()));
}
}
String findUser(int id) throws InterruptedException {
Thread.sleep(Duration.ofMillis(100));
return "ana";
}
int countOrders(int id) throws InterruptedException {
Thread.sleep(Duration.ofMillis(50));
return 3;
}
It prints:
Page[user=ana, orders=3]
These are the Java 25 calls:
StructuredTaskScope.open()opens a scope. Eachforkstarts the subtask in a new virtual thread.scope.join()waits for the subtasks.Subtask.get()returns a subtask’s result. It’s only allowed afterjoin: calling it earlier threwIllegalStateException: join not called.
The rules are strict. Closing a scope that forked but never joined threw IllegalStateException: Owner did not join after forking when we tried it.
Subtask is a nested type, so the program imports it. The automatic imports in a compact source file cover java.util.concurrent‘s top-level types, but not nested ones.
One failure cancels the rest
With open() and no arguments, the scope waits for every subtask to succeed. If one fails, it cancels the others. This program makes the order certain: the failing subtask waits until the other one has started, and the other one waits on a latch that never opens.
import java.util.concurrent.StructuredTaskScope.FailedException;
void main() throws InterruptedException {
var userStarted = new CountDownLatch(1);
var neverOpens = new CountDownLatch(1);
var userSaw = new AtomicReference<String>("nothing");
try (var scope = StructuredTaskScope.open()) {
scope.fork(() -> {
userStarted.countDown();
try {
neverOpens.await(); // a slow call that would never finish
userSaw.set("finished");
} catch (InterruptedException e) {
userSaw.set("interrupted");
}
});
scope.fork(() -> {
userStarted.await(); // fail only once the other subtask is waiting
throw new IllegalStateException("orders service is down");
});
scope.join();
IO.println("both succeeded");
} catch (FailedException e) {
IO.println("request failed: " + e.getCause().getMessage());
}
IO.println("the user subtask saw: " + userSaw.get());
}
It prints:
request failed: orders service is down
the user subtask saw: interrupted
When the second subtask threw, the scope interrupted the first. join() threw a StructuredTaskScope.FailedException, whose cause is the original exception. By the time the try block ended, the scope had waited for the interrupted subtask to finish, so userSaw was already set when main read it. Compare that with the executor version, where the slow task kept running.
Other joiners
A joiner decides what join() waits for and what it returns. You pass one to open:
import java.util.concurrent.StructuredTaskScope.Joiner;
import java.util.concurrent.StructuredTaskScope.Subtask;
void main() throws InterruptedException {
var slowWasCancelled = new AtomicBoolean();
try (var scope = StructuredTaskScope.open(Joiner.<String>anySuccessfulResultOrThrow())) {
scope.fork(() -> {
throw new IllegalStateException("mirror A is down");
});
scope.fork(() -> {
try {
new CountDownLatch(1).await(); // mirror C never answers
return "mirror C";
} catch (InterruptedException e) {
slowWasCancelled.set(true);
throw e;
}
});
scope.fork(() -> "mirror B");
String first = scope.join();
IO.println("first good answer: " + first);
}
IO.println("slow mirror cancelled: " + slowWasCancelled.get());
try (var scope = StructuredTaskScope.open(Joiner.<Integer>allSuccessfulOrThrow())) {
for (int n = 1; n <= 4; n++) {
int x = n;
scope.fork(() -> x * x);
}
List<Integer> squares = scope.join().map(Subtask::get).toList();
IO.println("squares: " + squares);
}
}
It prints:
first good answer: mirror B
slow mirror cancelled: true
squares: [1, 4, 9, 16]
anySuccessfulResultOrThrow() ignores failures while another subtask might still succeed. As soon as one returns, join() returns that result and the rest are cancelled. allSuccessfulOrThrow() makes join() return a Stream of the subtasks in the order you forked them, so the squares come out in order.
Java 25 also has awaitAll(), which waits for everything and never throws, and awaitAllSuccessfulOrThrow(), which gives you the same behaviour as open() with no arguments. With awaitAll(), one of our subtasks failed, join() returned normally, and that subtask’s state() was FAILED. A second argument to open configures the scope. With cf -> cf.withTimeout(Duration.ofMillis(100)), a slow subtask made join() throw StructuredTaskScope.TimeoutException.
Scoped values flow into subtasks
A subtask forked in a scope sees the scoped values that were bound when the scope was opened:
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
void main() {
ScopedValue.where(REQUEST_ID, "req-7").run(() -> {
try (var scope = StructuredTaskScope.open()) {
var user = scope.fork(() -> log("find user"));
var orders = scope.fork(() -> log("count orders"));
scope.join();
IO.println(user.get());
IO.println(orders.get());
String[] fromPlain = new String[1];
Thread.ofVirtual().start(() -> fromPlain[0] = log("plain thread")).join();
IO.println(fromPlain[0]);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
String log(String what) {
String id = REQUEST_ID.isBound() ? REQUEST_ID.get() : "no request id";
return "[" + id + "] " + what;
}
It prints:
[req-7] find user
[req-7] count orders
[no request id] plain thread
Both subtasks read req-7 on their own threads. The plain virtual thread started in the same place didn’t see it. Only a scope passes scoped values on, because a scope guarantees its subtasks end before the binding does. Nothing has to be copied or cleaned up.
Debugging virtual threads
A thread dump lists every thread and what it’s waiting on, but the classic jcmd <pid> Thread.print leaves virtual threads out. We checked, and none of our virtual threads appeared in it. Use Thread.dump_to_file instead. We ran a program that forks two subtasks, each sleeping 15 seconds, and dumped it while it waited (trimmed heavily):
$ java --enable-preview Main.java &
$ jcmd <pid> Thread.dump_to_file -format=json threads.json
$ cat threads.json
...
"container": "java.util.concurrent.StructuredTaskScopeImpl@bef2d72",
"parent": "<root>",
"owner": "3",
"threads": [
{
"tid": "26",
"virtual": true,
"state": "TIMED_WAITING",
"stack": [
...
"java.base\/java.lang.Thread.sleep(Thread.java:601)",
"Main.fetch(Main.java:11)",
"Main.lambda$main$0(Main.java:3)",
...
Threads are grouped by container. The two subtasks sit inside the scope, and its owner is thread 3, which is main. So the dump shows the structure of your code: which thread opened the scope and which subtasks belong to it. With a plain executor, the virtual threads are grouped under the executor instead, with no owner.
What to remember
- A virtual thread is a cheap
Threadthat mounts on a carrier thread only while it runs. Create one per task withExecutors.newVirtualThreadPerTaskExecutor(). They’re final since Java 21. - Virtual threads help blocking, IO-bound code. CPU-bound work gets nothing from them.
- Don’t pool virtual threads. Limit access to a scarce resource with a
Semaphore. - Since Java 24,
synchronizedno longer pins in most cases. A thread blocked in a static initializer or under a native frame still pins, and the JFR eventjdk.VirtualThreadPinnedshows where. ScopedValue, final in Java 25, binds an immutable value for the length of one call. Unlike aThreadLocal, it can’t be changed by code you call, and it can’t leak into the next task.StructuredTaskScopeis preview in Java 25. Subtasks forked in a scope finish before it closes, a failure cancels the others, and scoped values flow into them.- Find virtual threads with
jcmd <pid> Thread.dump_to_file -format=json, notThread.print.
Write the simple blocking code, and give each task its own virtual thread.