Java Structured Concurrency
Treat concurrent subtasks as a unit of work in Java with structured concurrency (StructuredTaskScope).
Java Structured Concurrency
Structured concurrency treats a group of concurrent subtasks as a single unit of work: they are launched together, they finish together, and if one fails or the caller is cancelled, the rest are cancelled too — no orphan threads outliving the block that started them. The model is delivered by java.util.concurrent.StructuredTaskScope (a preview API introduced in Java 21) and rests on the same virtual threads covered earlier in this part. The goal is simple: make concurrent code as easy to read, debug, and reason about as a plain sequential method.
Why "structured"?
Classic thread pools are unstructured: you submit a task to a shared ExecutorService and get back a Future whose lifetime is unrelated to the method that created it. A task can outlive its caller, an error in one task is invisible to its siblings, and cancellation has to be wired up by hand. The result is leaked threads and tangled error handling.
Structured concurrency borrows the discipline of structured control flow: just as a try block scopes its statements, a task scope confines its subtasks. Subtasks forked inside a block must all complete before the block exits. Lifetimes nest cleanly, so a thread dump and a stack trace actually tell you who started what.
| Concern | Unstructured (ExecutorService shared pool) | Structured (StructuredTaskScope) |
|---|---|---|
| Subtask lifetime | Independent of the caller | Bounded by the enclosing block |
| Error in one subtask | Hidden in a Future until you call get | Can short-circuit the whole scope |
| Cancellation | Manual, easy to forget | Automatic on failure or interrupt |
| Resource cleanup | Up to you | close() joins every subtask |
The shape of a scope
A scope is an AutoCloseable, so it lives in a try-with-resources block. You fork subtasks (each returns a Subtask handle), call join() to wait for them, then read each result. The ShutdownOnFailure policy cancels remaining subtasks the moment any one of them throws:
import java.util.concurrent.StructuredTaskScope;
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
StructuredTaskScope.Subtask<String> user = scope.fork(() -> fetchUser(id));
StructuredTaskScope.Subtask<Integer> order = scope.fork(() -> fetchOrderCount(id));
scope.join(); // wait for both branches
scope.throwIfFailed(); // rethrow if either branch failed
return new Profile(user.get(), order.get());
} // close() guarantees both subtasks have ended before we leaveIf fetchUser throws, ShutdownOnFailure interrupts the still-running fetchOrderCount, join() returns, and throwIfFailed() rethrows the original cause wrapped in an ExecutionException. You never leak a thread.
Built-in shutdown policies
The two supplied policies cover the common patterns; you subclass StructuredTaskScope for anything else.
| Policy | Finishes when | Use it for |
|---|---|---|
ShutdownOnFailure | All succeed, or one fails | Fan-out where you need every result (the common case) |
ShutdownOnSuccess<T> | First success, or all fail | Racing redundant sources; take the fastest answer |
ShutdownOnSuccess returns the winner via result() and cancels the losers:
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> queryMirrorA());
scope.fork(() -> queryMirrorB());
scope.join();
return scope.result(); // the first one to return; the slower is cancelled
}Deadlines and cancellation propagate
A scope can be joined with a deadline; when it elapses, unfinished subtasks are cancelled:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
scope.fork(() -> slowService());
scope.joinUntil(Instant.now().plusSeconds(2)); // throws TimeoutException if late
scope.throwIfFailed();
}Cancellation is cooperative and flows downward: if the thread that owns the scope is interrupted, every subtask is interrupted in turn. Because each subtask runs on its own virtual thread, forking thousands of them is cheap — the scope, not a fixed pool size, is the unit you reason about.
A worked example: fan-out, failure, and joining a list
StructuredTaskScope is a preview feature, so to keep this example runnable on a stable JDK we model the same idea with a virtual-thread-per-task executor: a try-with-resources block that scopes a group of subtasks and only exits once every subtask thread has terminated. It fans two calls out concurrently, then shows how a failure short-circuits the unit of work and how invokeAll joins a whole list at once.
What to take from the run:
- Both subtasks reported
is virtual : true— eachsubmitran on its own virtual thread, the same lightweight carrierStructuredTaskScope.forkuses, so spinning up one thread per subtask is cheap. - The happy-path block printed
ran concurrently (<320ms): trueeven though the two fetches sleep 120ms and 200ms: they overlapped, so wall time tracks the slowest branch (~200ms), not the sum (320ms). That overlap is the entire point of fanning out. - Leaving the try-with-resources block called
close(), which blocked until every subtask thread terminated — the scope is the unit of lifetime, exactly the disciplineStructuredTaskScopeenforces by construction. - In the failure section the program printed
caught: IllegalStateException -> upstream said no: an error thrown inside a subtask surfaces at the join point wrapped inExecutionException, andgetCause()gives you back the original exception. - After catching the failure it printed
sibling cancelled: true— we cancelled the still-runninggoodbranch so no orphan outlived the block, which is precisely whatShutdownOnFailuredoes for you automatically; here we did it by hand to show the mechanism.
Practice
With StructuredTaskScope.ShutdownOnFailure, what happens to the other forked subtasks when one of them throws an exception?