Topic 13.11
Structured Concurrency and Scoped Values
In one line
Structured concurrency (preview in Java 21 to 25) treats a group of related subtasks as one unit: they start in a scope, finish or fail together, and are cancelled together. Scoped values (final in Java 25) share read-only data with everything a task calls, a safer replacement for ThreadLocal.
Think of it like this
A teacher takes a class on a field trip. The trip is the scope: every child who leaves the school must come back on the same bus, and if one child gets hurt, the whole class returns early. Nobody wanders off on their own and is forgotten. That's structured concurrency. The name tag each child wears for the day, readable by anyone they meet and taken off when the trip ends, is a scoped value.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Structured concurrency
- Treating subtasks started by a piece of code as part of that code: they all finish, or are cancelled, before it continues.
- Scope
- The block of code that owns a group of subtasks and waits for all of them.
- Fork
- Start a subtask that runs concurrently with the rest of the scope.
- Preview feature
- A finished but not yet permanent Java feature you must enable with --enable-preview. It may still change.
- Scoped value
- A read-only value made available to a block of code and everything it calls, for exactly as long as that block runs.
- ThreadLocal
- The older way to give each thread its own copy of a value, which must be cleaned up by hand.
- Thread leak
- A thread or task left running after the code that started it has finished, often forgotten.
Step by step
01The problem with unstructured tasks
A handler submits two calls to an executor and waits for both futures. If the first get() throws, the method returns early, and the second task keeps running in the background with no one waiting for it. If the handler thread is interrupted, the subtasks don't know.
The relationship between the handler and its subtasks exists only in the programmer's head, not in the code.
Future<String> user = executor.submit(() -> findUser());
Future<Integer> orders = executor.submit(() -> countOrders());
String u = user.get(); // if this throws...
int o = orders.get(); // ...countOrders() keeps running, unobserved02A structured scope
In the scope, both subtasks are forked into virtual threads. join() waits for both. If either fails, the scope cancels the other and join() throws; either way, by the time the try block ends, no subtask is still running.
The shape mirrors ordinary method calls: a block that starts work also finishes it.
03Running preview code
Preview features are off by default. Compile and run with --enable-preview and the matching --release/--source (for single-file programs: java --enable-preview --source 25 Main.java). The compiler prints a note that the program uses preview features.
Because preview APIs change (the Java 21 version used new StructuredTaskScope.ShutdownOnFailure() and throwIfFailed(); Java 25 uses StructuredTaskScope.open()), keep preview code isolated and expect to update it on each JDK upgrade.
04Scoped values: bind, call, unbind
Declare static final ScopedValue<String> USER = ScopedValue.newInstance();. Bind it for a call with ScopedValue.where(USER, "asha").run(task). Inside the task (and anything it calls), USER.get() returns "asha"; after run returns, USER.isBound() is false again.
A nested where re-binds the value for an inner call only, then the outer value comes back, like local variables shadowing in nested blocks.
05ThreadLocal's problems, fixed
ThreadLocal.set with no remove in a thread pool leaks the value into the next task on that thread, a source of security bugs (one user's data showing up in another's request). Scoped values can't leak: the binding ends with the call. They're also immutable, so a callee can't change data its caller relies on.
Try it yourself
- 1
Make a subtask fail
On JDK 25, change
countOrdersto thrownew IllegalStateException("orders down"). Run with--enable-preview:join()throwsStructuredTaskScope.FailedExceptionwhose cause is your exception, andfindUseris cancelled if still running. - 2
Forget remove()
In the ThreadLocal example (runs here), delete the
finallyblock. Nowafter:printsasha: the value outlives the request. In a thread pool, the next task on this thread would see it.
Code & diagrams
Run on JDK 25 with: java --enable-preview --source 25 Main.java (preview APIs may change in later releases).
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
public class Main {
record Page(String user, int orders) {}
static String findUser() throws InterruptedException {
Thread.sleep(30);
return "asha";
}
static int countOrders() throws InterruptedException {
Thread.sleep(20);
return 3;
}
public static void main(String[] args) throws InterruptedException {
try (var scope = StructuredTaskScope.open()) {
Subtask<String> user = scope.fork(() -> findUser());
Subtask<Integer> orders = scope.fork(() -> countOrders());
scope.join(); // waits for both; throws if either failed
System.out.println(new Page(user.get(), orders.get()));
}
}
}Output
Page[user=asha, orders=3]Run on JDK 25 with: java Main.java
public class Main {
static final ScopedValue<String> USER = ScopedValue.newInstance();
public static void main(String[] args) {
ScopedValue.where(USER, "asha").run(() -> handleRequest());
System.out.println("outside: bound? " + USER.isBound());
}
static void handleRequest() {
System.out.println("handling request for " + USER.get());
ScopedValue.where(USER, "admin").run(() -> System.out.println("nested: " + USER.get()));
System.out.println("back to " + USER.get());
}
}Output
handling request for asha
nested: admin
back to asha
outside: bound? falseExpected output
handling request for asha
after: nullBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Use a preview API without enabling it
Run the structured concurrency example with plain java Main.java on JDK 25.
Myth vs fact
Myth
Structured concurrency is a final feature.
Fact
It was still a preview in Java 25. Scoped values became final in Java 25.
Myth
Scoped values are just ThreadLocals with a new name.
Fact
They're immutable, bound for a fixed call, automatically removed, and cheap to inherit by child tasks.
Myth
A scope lets subtasks keep running after it closes.
Fact
Closing the scope waits for or cancels every subtask: none can outlive it.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Subtasks forked in a
StructuredTaskScopeinherit the parent's scoped-value bindings automatically, which is what makes request context flow into parallel calls without ThreadLocal copying. - ▸
The Java 25 preview uses
Joinerpolicies to decide howjoin()completes: by default all must succeed, whileJoiner.anySuccessfulResultOrThrow()returns the first success and cancels the rest (the oldShutdownOnSuccess). - ▸
JSON thread dumps (
jcmd <pid> Thread.dump_to_file -format=json) show thread containers, so subtasks forked in a scope appear nested under their owner, which makes debugging concurrent fan-out much easier.
Remember this
- 1
With plain executors, a method can start tasks that outlive it: if one call fails, the others keep running and wasting work, and nobody notices a task left behind (a thread leak). Structured concurrency says: subtasks forked inside a scope must all finish before the scope's code block ends, just like a method call returns before its caller continues.
- 2
The API is **
StructuredTaskScope. It was a preview** feature from Java 21 to Java 25 and its shape changed between previews. In Java 25 (JEP 505, fifth preview) youopen()a scope in try-with-resources,fork()subtasks (each runs in its own virtual thread),join()to wait, then read eachSubtaskwithget(). By default, if any subtask fails, the others are cancelled andjoin()throws. Preview APIs need--enable-previewand may change, so treat this code as a preview of the future. - 3
Benefits: cancellation propagates (one failure stops the siblings), no leaks (the scope can't end with running children), and thread dumps show the structure (subtasks appear as children of the task that forked them).
- 4
**
ScopedValue<T>(final in Java 25**, JEP 506) holds a value that's bound for the duration of a call:ScopedValue.where(USER, "asha").run(() -> handle()). Any code called fromhandle(), including subtasks forked in a structured scope, can readUSER.get(). Whenrunreturns, the binding disappears. - 5
Compared with
ThreadLocal, scoped values are immutable (noset, only nested re-binding), have a bounded lifetime (no leaks from forgettingremove(), a common ThreadLocal bug in thread pools), and are cheap to inherit by many virtual threads. Typical uses: the current user, a request ID for logging, a transaction context.
Explain it without notes
What problem does structured concurrency solve?
How are scoped values better than ThreadLocal?
Practice
Write (for JDK 25) a scoped value REQUEST_ID and a method log(String msg) that prints the request id with every message; bind it for two different requests.
Trade-offs
- ↔
Structured scopes make concurrent code safer and easier to reason about, but the API was still in preview through Java 25 and may change.
- ↔
Scoped values are safe and cheap but immutable; per-thread mutable caches still need ThreadLocal (used carefully).
Done when you can
Done when you can explain structured concurrency with the fork, join and cancel-on-failure rules.
Done when you know StructuredTaskScope is a preview API through Java 25 and how to run preview code.
Done when you can bind and read a ScopedValue and explain why it beats ThreadLocal.