Structured Concurrency
AdvancedStructured Concurrency (Java 21) scopes subtask lifetimes to their parent, preventing thread leaks and simplifying error handling in concurrent code.
Overview
Structured Concurrency (java.util.concurrent.StructuredTaskScope, Java 21 preview, stabilised in Java 23) treats a group of concurrent tasks as a single unit of work. When the scope closes, all subtasks are guaranteed to be complete or cancelled. ShutdownOnFailure cancels all siblings if any task fails. ShutdownOnSuccess returns the first success and cancels the rest. This eliminates the thread-leak and partial-result problems common with raw ExecutorService and CompletableFuture.
StructuredTaskScope Basics
Open a StructuredTaskScope with try-with-resources. Fork subtasks with scope.fork(). Call scope.join() to wait for completion. scope.throwIfFailed() propagates any failure.
The critical invariant: when the try block exits (normally or via exception), all forked tasks are guaranteed to be done — no orphaned threads.
import java.util.concurrent.*;
record UserProfile(User user, List<Order> orders, AccountStatus status) {}
UserProfile fetchProfile(long userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Fork three concurrent fetches
Subtask<User> userTask = scope.fork(() -> userDb.find(userId));
Subtask<List<Order>> ordersTask = scope.fork(() -> orderDb.findByUser(userId));
Subtask<AccountStatus> statusTask = scope.fork(() -> accountSvc.getStatus(userId));
scope.join() // wait for all three
.throwIfFailed(); // if any threw, re-throw here
// All succeeded — get results
return new UserProfile(
userTask.get(),
ordersTask.get(),
statusTask.get()
);
}
// Scope closed: all subtasks guaranteed complete
// If any failed: remaining were cancelled, exception propagated
}ShutdownOnSuccess — First Result Wins
ShutdownOnSuccess is perfect for the hedging pattern: send the same request to multiple sources (primary DB, replica, cache) and return whichever responds first, cancelling the rest.
This is the structured-concurrency equivalent of CompletableFuture.anyOf() but with guaranteed cleanup.
String fetchWithHedging(String key) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
// Race three sources — first to succeed wins
scope.fork(() -> primaryCache.get(key));
scope.fork(() -> replicaDb.get(key));
scope.fork(() -> slowPrimaryDb.get(key));
scope.join(); // wait until first succeeds (or all fail)
return scope.result(ex ->
new RuntimeException("All sources failed for key: " + key, ex));
}
// Scope closed: losing subtasks were cancelled
}
// Timeout with structured concurrency
String fetchWithTimeout(String key) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> fetchData(key));
// joinUntil — deadline-based wait
scope.joinUntil(Instant.now().plusSeconds(3));
return scope.result(ex -> new TimeoutException("Fetch timed out"));
}
}Structured Concurrency vs CompletableFuture
CompletableFuture is powerful but error-prone: a failed future may leave other futures running indefinitely (thread leaks). Cancellation must be implemented manually. Error handling is scattered across exceptionally/handle chains.
Structured Concurrency enforces these invariants automatically: scope lifetime bounds subtask lifetime, errors propagate cleanly, and observability tools (debuggers, profilers) understand the parent-child relationship.
// CompletableFuture — subtasks can outlive their logical scope
CompletableFuture<User> userFuture = fetchUserAsync(id);
CompletableFuture<Order> orderFuture = fetchOrderAsync(id);
// If userFuture fails and we don't cancel orderFuture,
// it keeps running and consuming resources indefinitely!
CompletableFuture.allOf(userFuture, orderFuture)
.exceptionally(ex -> {
orderFuture.cancel(true); // must remember to cancel manually
return null;
});
// Structured Concurrency — automatic lifetime management
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> user = scope.fork(() -> fetchUser(id));
Subtask<Order> order = scope.fork(() -> fetchOrder(id));
scope.join().throwIfFailed();
// If either fails → both cancelled → scope closes cleanly
// No manual cancellation needed
process(user.get(), order.get());
}Key Points to Remember
- StructuredTaskScope guarantees all subtasks finish when the scope closes — no thread leaks.
- ShutdownOnFailure: first failure cancels all siblings and re-throws.
- ShutdownOnSuccess: first success cancels all siblings and returns the result.
- joinUntil(Instant) provides deadline-based waiting.
- Structured Concurrency makes thread relationships visible to debuggers and profilers.
Practice Structured Concurrency in the Playground
Run and modify code directly in your browser - no setup needed.
Interview Questions
Sign in to ask AriaWhat problem does Structured Concurrency solve that CompletableFuture does not?
What is the difference between ShutdownOnFailure and ShutdownOnSuccess?
How does StructuredTaskScope prevent thread leaks?
How would you implement the hedging pattern with Structured Concurrency?
What Java version stabilised Structured Concurrency?
Ask Aria about Structured Concurrency
Your personal AI tutor — ask anything about this concept