W3docs

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.

ConcernUnstructured (ExecutorService shared pool)Structured (StructuredTaskScope)
Subtask lifetimeIndependent of the callerBounded by the enclosing block
Error in one subtaskHidden in a Future until you call getCan short-circuit the whole scope
CancellationManual, easy to forgetAutomatic on failure or interrupt
Resource cleanupUp to youclose() 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 leave

If 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.

PolicyFinishes whenUse it for
ShutdownOnFailureAll succeed, or one failsFan-out where you need every result (the common case)
ShutdownOnSuccess<T>First success, or all failRacing 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.

java— editable, runs on the server

What to take from the run:

  • Both subtasks reported is virtual : true — each submit ran on its own virtual thread, the same lightweight carrier StructuredTaskScope.fork uses, so spinning up one thread per subtask is cheap.
  • The happy-path block printed ran concurrently (<320ms): true even 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 discipline StructuredTaskScope enforces 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 in ExecutionException, and getCause() gives you back the original exception.
  • After catching the failure it printed sibling cancelled: true — we cancelled the still-running good branch so no orphan outlived the block, which is precisely what ShutdownOnFailure does for you automatically; here we did it by hand to show the mechanism.

Practice

Practice

With StructuredTaskScope.ShutdownOnFailure, what happens to the other forked subtasks when one of them throws an exception?