When a request splits into several parallel tasks, such as fetching customer data and order status at the same time, we usually reach for ExecutorService and Future. It works, but it hides a flaw that is easy to miss, and that flaw is why structured concurrency exists: if one task fails, the others keep running even though nobody needs their results, and when the method returns, those threads are still alive. Structured concurrency fixes the root cause with one simple rule: every child task must finish before the block of code that created it finishes. This article explains the idea, runs StructuredTaskScope and ScopedValue for real on JDK 21, and then compares them with the API as it changed in JDK 25 and 26.
This article follows Virtual threads in Java 21: when to replace a thread pool: virtual threads make threads cheap, and structured concurrency answers the question of how to manage the lifecycle of all those threads correctly. For the basics of thread pools and CompletableFuture, see Concurrency patterns: Producer-Consumer, thread pools and CompletableFuture.
The problem: orphaned child tasks with ExecutorService
Let us start by demonstrating the flaw that structured concurrency targets. The program Demo1FutureLeak.java calls two tasks in parallel: findUser() takes 1 second, and fetchOrder() throws an error after 100 ms. The main code waits for user.get() first and only then calls order.get():
Future<String> user = ex.submit(Demo1FutureLeak::findUser);
Future<Integer> order = ex.submit(Demo1FutureLeak::fetchOrder);
String u = user.get(); // waits for user first: 1 second, even though order failed at 100 ms
int o = order.get();Real output on JDK 21 (Apple M4, 16 GB):
[ 4 ms] fetchOrder started
[ 4 ms] findUser started
[ 115 ms] fetchOrder threw an error
[1015 ms] findUser finished (still running, nobody needs the result)
[1016 ms] main handle() failed: java.lang.IllegalStateException: warehouse is not respondingThe error happens at 115 ms, but handle() only learns about it at 1,016 ms, and during all that time findUser burns resources on a result that will be thrown away. These are exactly the weaknesses JEP 453 lists: the lifecycle of a child task is not tied to its parent, so a failure leaks threads; interrupts do not propagate to child tasks; one slow task can delay the error handling of another; and a thread dump does not show the parent-child relationship. To avoid this without structured concurrency, you must hand-write cancel(true) in every finally block, which is very easy to forget.
Structured concurrency: the "go statement considered harmful" idea
The idea of structured concurrency is not unique to Java.
In 2018, Nathaniel J. Smith wrote Notes on structured concurrency, or: Go statement considered harmful, comparing "fire and forget" background tasks (the go statement, spawning a thread, registering a callback) with the goto statement that Dijkstra once criticized: control flow branches out of the function, and a function call can implicitly spawn background work that outlives the function itself. His solution is the "nursery": a block that the parent must open before spawning children and cannot leave until every child is done. Smith names four consequences: when a function returns, its work is done; a child's error is rethrown in the parent after the other children are cancelled; cancellation propagates in an orderly way; and resources (files, connections) are not closed while a background task is still using them.
Project Loom brought structured concurrency to Java: as an incubator in JDK 19 and 20 (JEP 428, 437), and as a preview API since JDK 21 (JEP 453). The link to virtual threads is natural: because virtual threads are cheap, every child task can have its own thread, and StructuredTaskScope by default forks each subtask into a new virtual thread.
Structured concurrency in Java 21: StructuredTaskScope and ShutdownOnFailure
Here is the same scenario rewritten with structured concurrency using StructuredTaskScope.ShutdownOnFailure (code/s6-32/Demo2Scope.java):
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<String> user = scope.fork(Demo2Scope::findUser);
Subtask<Integer> order = scope.fork(Demo2Scope::fetchOrder);
scope.join().throwIfFailed();
log("result " + user.get() + order.get());
} catch (ExecutionException e) {
log("handle() failed: " + e.getCause());
}Because this is a preview API, you must enable a flag when compiling and running: java --enable-preview --source 21 Demo2Scope.java. Without the flag, the compiler refuses:
Demo2Scope.java:2: error: StructuredTaskScope is a preview API and is disabled by default.
(use --enable-preview to enable preview APIs)With the flag, the result is very different from before:
[ 2 ms] findUser started
[ 3 ms] fetchOrder started
[ 112 ms] fetchOrder threw an error
[ 114 ms] findUser was interrupted
[ 114 ms] main handle() failed: java.lang.IllegalStateException: warehouse is not responding
[ 114 ms] main leaving handle(): all subtasks have finishedTwo ms after fetchOrder fails, findUser is interrupted and the whole of handle() ends at 114 ms instead of 1,016 ms. The mechanism has the following parts, according to JEP 453:
- Owner: the thread that creates the scope. Only the owner may call
join. Forfork, the owner and the threads inside that same scope (subtasks forked from the scope) are allowed; a thread outside the scope that callsforkgets aWrongThreadException. fork(Callable)creates a subtask in a new thread (a virtual thread by default) and returns aSubtask.join()waits until all subtasks are done or cancelled. Calling it is mandatory.shutdown()cancels unfinished subtasks by interrupting their threads and blocks further forks.ShutdownOnFailurecalls it automatically when the first subtask fails.close(), usually called implicitly by try-with-resources, waits until every subtask has actually finished. As a result, when you leave thetryblock, no thread is left behind.
An important consequence of structured concurrency: if the owner thread is interrupted during join(), all subtasks are cancelled too. Cancellation follows the structure of the code rather than the discipline of the programmer. Note that an interrupt only works on code that responds to interrupts, such as Thread.sleep and JDK I/O; a pure CPU loop that never checks the interrupt flag will not stop.
Subtask: read only after join
fork returns a Subtask whose state() is SUCCESS, FAILED, or UNAVAILABLE. Calling get() before join(), or forgetting join(), is a programming error that the API catches immediately (Demo4Violation.java):
get() before join -> IllegalStateException: Owner did not join after forking subtasks
after join: state=SUCCESS, get()=1
closing the scope without join -> IllegalStateException: Owner did not join after forking subtasksThis is a nice property of the structured concurrency design: Future.get() can be called at any time and blocks, while Subtask.get() never blocks and is valid only after the join, so you cannot accidentally create a hidden wait point.
ShutdownOnSuccess and deadlines
The second policy in structured concurrency is ShutdownOnSuccess: take the result of the first successful subtask and cancel the rest. For example, ask three carriers for a shipping quote in parallel (Demo3Success.java, timings simulated with Thread.sleep):
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> quote("GHN", 300));
scope.fork(() -> quote("GHTK", 120));
scope.fork(() -> quote("VTP", 500));
System.out.println("Fastest quote: " + scope.join().result());
} VTP cancelled after another result arrived
GHN cancelled after another result arrived
Fastest quote: GHTK:21200 VND after 131 msThe two slow subtasks are interrupted as soon as GHTK answers, without waiting the full 500 ms. To set a deadline, use joinUntil(Instant); when it expires it throws TimeoutException, and close() still cancels the subtasks that are running:
--- 200 ms deadline, both are slower ---
A cancelled after another result arrived
B cancelled after another result arrived
TimeoutException after 206 ms(The message "cancelled after another result arrived" is a fixed string in the example code; in the second case it actually means cancelled when the deadline expired.) If you need a different policy, such as collecting every successful result and ignoring failures, extend StructuredTaskScope and override handleComplete.
Observing structured concurrency with a thread dump
The third benefit of structured concurrency is observability. A text thread dump (jstack) does not include virtual threads, but jcmd <pid> Thread.dump_to_file -format=json <file> writes virtual threads too and groups them by "container". We run Demo7Dump.java (a scope with two subtasks that sleep for 8 seconds) and then take a dump. The jcmd output is a long JSON file (system threads, ForkJoinPool, and so on), so this article only excerpts the scope's container with the script dump_view.py; the full command is in run_dump.sh and the result is in run_Demo7Dump.log:
java.util.concurrent.StructuredTaskScope$ShutdownOnFailure@232204a1 | threads: 2
thread 20 ''
thread 22 ''The two virtual threads (with empty names) sit neatly under the container of the ShutdownOnFailure scope, instead of being mixed in with thousands of threads from a shared pool. When a request hangs, one look at the dump tells you which subtasks belong to the same scope.
Scoped values in structured concurrency: replacing ThreadLocal
Subtasks in structured concurrency run on other threads, so how do we pass context such as a trace id or the current user? ThreadLocal cannot do this cheaply: JEP 506 points out three drawbacks. First, the variable can be modified arbitrarily (mutable). Second, the lifetime is unbounded: the value lives until remove is called, and forgetting to call it causes leaks or contamination between tasks in a thread pool. Third, inheritance is expensive: a child thread copies the whole InheritableThreadLocal, which is very heavy with millions of threads.
ScopedValue was introduced as the replacement: immutable, one-way, and with a lifetime tied to a block of code. In JDK 21 it is a preview (JEP 446). Demo5ScopedValue.java combines both APIs:
static final ScopedValue<RequestContext> CTX = ScopedValue.newInstance();
ScopedValue.where(CTX, new RequestContext("trace-7f3a", "customer-01")).run(() -> {
System.out.println(handle()); // handle() forks two subtasks, each reads CTX.get()
ScopedValue.where(CTX, new RequestContext("trace-7f3a", "admin")).run(() ->
System.out.println("rebind inside: " + describe()));
System.out.println("after rebind: " + describe());
});
System.out.println("after run: " + describe());outside scope: (no context)
stock sees trace-7f3a/customer-01
wallet sees trace-7f3a/customer-01
rebind inside: trace-7f3a/admin
after rebind: trace-7f3a/customer-01
after run: (no context)
--- ThreadLocal: the subtask does NOT see the owner's value unless Inheritable is used ---
subtask sees ThreadLocal = nullThere are four things to read from the output. First, the two subtasks run on other threads yet still see the owner's context: StructuredTaskScope lets child threads inherit the binding without copying anything. Second, rebinding takes effect only inside the nested block; outside it the old value returns. Third, once you leave run the binding disappears, so there is nothing to forget to clean up. Fourth, a plain ThreadLocal does not pass to child threads automatically (the last line is null). One more note from the JEP: ForkJoinPool does not support scoped value inheritance, because it does not guarantee that child threads finish before the parent's scope.
Scoped values do not replace every use case of ThreadLocal. Two-way communication (a callee setting a value for a distant caller) or a per-thread cache for objects that are expensive to create still belongs to ThreadLocal; the JEP does not intend to remove it.
The structured concurrency API across versions
Structured concurrency is an evolving API, so stating the version is mandatory. According to the JEPs:
| JDK | StructuredTaskScope | ScopedValue |
|---|---|---|
| 19, 20 | Incubator (JEP 428, 437) | JDK 20: incubator (JEP 429) |
| 21 (LTS) | Preview (JEP 453) | Preview (JEP 446) |
| 22, 23, 24 | Preview again (JEP 462, 480, 499) | Refined (JEP 464, 481, 487) |
| 25 (LTS) | Fifth preview (JEP 505): switches to open() and Joiner | Final (JEP 506) |
| 26 | Sixth preview (JEP 525) | Final |
The biggest change to structured concurrency is in JDK 25: there is no more new StructuredTaskScope.ShutdownOnFailure(); instead you use the static method StructuredTaskScope.open(), and the completion policy is split out into a Joiner object. We run Demo6Jdk25.java on JDK 25 (LTS) with --enable-preview --source 25:
try (var scope = StructuredTaskScope.open()) { // wait for all to succeed
Subtask<String> user = scope.fork(() -> call("user", 100));
Subtask<String> order = scope.fork(() -> call("order", 150));
scope.join();
}
try (var scope = StructuredTaskScope.open(Joiner.<String>anySuccessfulResultOrThrow())) { ... }
try (var scope = StructuredTaskScope.open(Joiner.<String>awaitAllSuccessfulOrThrow(),
cf -> cf.withTimeout(Duration.ofMillis(200)))) { ... }open(): user@trace-9b1c | order@trace-9b1c
anySuccessful: GHTK@trace-9b1c
timeout: TimeoutExceptionThe mapping: ShutdownOnFailure becomes the default open(), ShutdownOnSuccess becomes Joiner.anySuccessfulResultOrThrow(), and joinUntil becomes the withTimeout configuration when opening the scope. The TRACE value of type ScopedValue in this program is still visible to the subtasks. In JDK 26 (JEP 525, March 2026) the API changes again: anySuccessfulResultOrThrow is renamed to anySuccessfulOrThrow, allSuccessfulOrThrow returns a list of results instead of a stream of subtasks, and Joiner.onTimeout() is added. We verify exactly that: running the same file on JDK 26, the compiler reports the old method as missing, while the renamed version (Demo6Jdk26.java) runs with the same result:
The first line of each log (the full JDK version) is trimmed here; see run_Demo6_jdk25.log and run_Demo6_jdk26.log. The compile error on JDK 26 is saved in compile_error_jdk26.txt; note that JDK 26 was no longer on the machine when this article was rewritten, so the compile error output was saved from an earlier run and was not re-run.
Demo6Jdk25.java:27: error: cannot find symbol
symbol: method <String>anySuccessfulResultOrThrow()
---
open(): user@trace-9b1c | order@trace-9b1c
anySuccessful: GHTK@trace-9b1c
timeout: TimeoutExceptionThe practical lesson: because it is still a preview, structured concurrency code that uses StructuredTaskScope may not recompile when you upgrade the JDK. A good rule is to wrap this API in a thin project-owned layer (for example a parallel(task1, task2) method), so that when the API changes you fix just one place.
When to use structured concurrency and when not to
- Use structured concurrency for: fan-out within a request (call several services and combine the results), a "race" that takes the first result, and tasks that must all be cancelled when one part fails.
- Not a good fit: background tasks that outlive the request (sending an email after responding) because they deliberately escape the scope; continuous event pipelines; and stream-style parallel data processing, where
parallelStreamis still the right tool. - Be careful: production environments accept only official APIs. Today
ScopedValuehas been final since JDK 25, whileStructuredTaskScopeis still a preview and requires--enable-previeweverywhere you build, run, and test. - Current alternatives: on JDK 21 without preview enabled, the safest options are
CompletableFuture.allOfwith manual cancellation, orExecutorService.invokeAnyfor the "take the first result" case.
Common mistakes
- Forgetting
--enable-previewin one of the steps (javac, java, tests, IDE): you get a "preview API disabled" error. - Forgetting
join(): throwsIllegalStateException, as seen above. - Calling fork from a thread outside the scope: rejected with
WrongThreadException; the owner and the threads inside the scope may fork, whilejoinis for the owner only. - A subtask that does not respond to interrupts: the scope cannot cancel a pure CPU loop;
close()will wait for it to finish. - Using ThreadLocal to pass context into subtasks: the subtask does not see the value (see the output above); use
ScopedValue. - Treating preview structured concurrency as stable: method names changed twice from JDK 21 to 26.
Structured concurrency checklist
- Create every child task inside the scope's
try-with-resources, and calljoin()before reading results. - Pick the right policy: wait for all (
ShutdownOnFailureoropen()), take the first (ShutdownOnSuccessoranySuccessful...). - Set a deadline on every fan-out that calls an external service.
- Pass context with
ScopedValue, not with scattered parameters orThreadLocal. - Use
jcmd Thread.dump_to_file -format=jsonto see the task tree when diagnosing. - State the JDK version and wrap the preview API in a thin layer.
In short, structured concurrency turns "spawn threads and remember to clean up" into a rule of the code structure: a child task cannot outlive the block that created it. Main references: JEP 453: Structured Concurrency (Preview), JEP 505: Structured Concurrency (Fifth Preview), JEP 525 (Sixth Preview), and JEP 506: Scoped Values.
Next article
After structured concurrency and modern concurrency, the series turns to the opposite view: designs that look reasonable but damage code over time. Continue with Anti-patterns in Java: God object, anemic model, service locator.



