Java 21 — Structured Concurrency
Understand Java's Structured Concurrency API — StructuredTaskScope, ShutdownOnFailure, ShutdownOnSuccess, and how it prevents thread leaks in concurrent code.
Structured concurrency ensures that when a task splits into concurrent subtasks, they all complete before the parent continues — and if one fails, the others are cancelled. No orphan threads, no forgotten futures, no leaked resources.
It was a preview feature in Java 21 and finalized in Java 25. The API works with virtual threads for lightweight concurrency.
The problem with unstructured concurrency
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<User> userFuture = executor.submit(() -> fetchUser(userId));
Future<List<Order>> ordersFuture = executor.submit(() -> fetchOrders(userId));
User user = userFuture.get(); // blocks until done
List<Order> orders = ordersFuture.get(); // blocks until done
What if fetchUser throws? fetchOrders keeps running. We wait for it unnecessarily. What if fetchOrders takes 30 seconds and we’ve already failed? The thread is wasted.
What if the parent thread is interrupted? Both futures keep running in the background — orphan tasks.
StructuredTaskScope — The solution
import java.util.concurrent.StructuredTaskScope;
record UserProfile(User user, List<Order> orders) {}
UserProfile fetchProfile(String userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var userTask = scope.fork(() -> fetchUser(userId));
var ordersTask = scope.fork(() -> fetchOrders(userId));
scope.join(); // wait for both
scope.throwIfFailed(); // propagate first failure
return new UserProfile(userTask.get(), ordersTask.get());
}
}
What happens:
fork()starts each subtask in a virtual threadjoin()waits for all subtasks to completethrowIfFailed()throws if any subtask failed- If one fails,
ShutdownOnFailurecancels the other try-with-resourcesensures cleanup
If fetchUser fails
1. fork(fetchUser) → starts virtual thread
2. fork(fetchOrders) → starts virtual thread
3. fetchUser throws exception
4. ShutdownOnFailure cancels fetchOrders
5. join() returns (both tasks done or cancelled)
6. throwIfFailed() throws the fetchUser exception
No orphan threads. No wasted work. Clean error propagation.
ShutdownOnFailure vs ShutdownOnSuccess
ShutdownOnFailure
“I need all results. If any fails, cancel the rest.”
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var a = scope.fork(() -> fetchA());
var b = scope.fork(() -> fetchB());
var c = scope.fork(() -> fetchC());
scope.join();
scope.throwIfFailed();
return combine(a.get(), b.get(), c.get());
}
Use when all subtasks are required. If any one fails, the combined result is useless.
ShutdownOnSuccess
“I need one result. The first success wins, cancel the rest.”
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> fetchFromServerA());
scope.fork(() -> fetchFromServerB());
scope.fork(() -> fetchFromServerC());
scope.join();
return scope.result(); // first successful result
}
Use for: redundant requests to multiple replicas, speculative execution, failover.
Practical examples
API aggregation
record DashboardData(
User user,
List<Notification> notifications,
List<Transaction> recentTransactions,
AccountSummary summary
) {}
DashboardData loadDashboard(String userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> userService.getUser(userId));
var notifications = scope.fork(() -> notificationService.getUnread(userId));
var transactions = scope.fork(() -> transactionService.getRecent(userId, 10));
var summary = scope.fork(() -> accountService.getSummary(userId));
scope.join();
scope.throwIfFailed();
return new DashboardData(
user.get(),
notifications.get(),
transactions.get(),
summary.get()
);
}
}
Four API calls run concurrently. Total latency = max(individual latencies), not sum. If any fails, all are cancelled.
With timeout
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> fetchUser(userId));
var orders = scope.fork(() -> fetchOrders(userId));
scope.joinUntil(Instant.now().plusSeconds(5)); // 5-second timeout
scope.throwIfFailed();
return new UserProfile(user.get(), orders.get());
}
If tasks don’t complete within 5 seconds, joinUntil throws TimeoutException and the scope cancels remaining tasks.
Partial results with custom scope
When you want best-effort results (some can fail):
record SearchResults(
List<Result> webResults,
List<Result> imageResults,
List<Result> videoResults
) {}
SearchResults search(String query) throws InterruptedException {
try (var scope = new StructuredTaskScope<List<Result>>()) {
var web = scope.fork(() -> searchWeb(query));
var images = scope.fork(() -> searchImages(query));
var videos = scope.fork(() -> searchVideos(query));
scope.join();
return new SearchResults(
getOrDefault(web, List.of()),
getOrDefault(images, List.of()),
getOrDefault(videos, List.of())
);
}
}
static <T> T getOrDefault(StructuredTaskScope.Subtask<T> task, T defaultValue) {
return switch (task.state()) {
case SUCCESS -> task.get();
case FAILED, UNAVAILABLE -> defaultValue;
};
}
If image search fails, you still return web and video results.
Structured concurrency vs CompletableFuture
// CompletableFuture — unstructured
var userFuture = CompletableFuture.supplyAsync(() -> fetchUser(id));
var ordersFuture = CompletableFuture.supplyAsync(() -> fetchOrders(id));
CompletableFuture.allOf(userFuture, ordersFuture).join();
// If userFuture fails, ordersFuture keeps running
// If main thread is interrupted, both keep running
// Structured concurrency
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> fetchUser(id));
var orders = scope.fork(() -> fetchOrders(id));
scope.join();
scope.throwIfFailed();
// If user fails, orders is cancelled
// If main thread is interrupted, both are cancelled
// When scope closes, all threads are cleaned up
}
| Aspect | CompletableFuture | StructuredTaskScope |
|---|---|---|
| Cancellation | Manual | Automatic on failure |
| Thread leak prevention | Your responsibility | Built-in |
| Error propagation | Complex chaining | throwIfFailed() |
| Timeout | orTimeout() per future | joinUntil() for all |
| Readability | Callback chains | Linear code |
Nesting scopes
Structured concurrency is hierarchical — scopes can nest:
UserProfile fetchProfile(String userId) throws Exception {
try (var outer = new StructuredTaskScope.ShutdownOnFailure()) {
var user = outer.fork(() -> fetchUser(userId));
var enrichedOrders = outer.fork(() -> fetchEnrichedOrders(userId));
outer.join();
outer.throwIfFailed();
return new UserProfile(user.get(), enrichedOrders.get());
}
}
List<EnrichedOrder> fetchEnrichedOrders(String userId) throws Exception {
List<Order> orders = fetchOrders(userId);
try (var inner = new StructuredTaskScope.ShutdownOnFailure()) {
var tasks = orders.stream()
.map(order -> inner.fork(() -> enrichOrder(order)))
.toList();
inner.join();
inner.throwIfFailed();
return tasks.stream().map(StructuredTaskScope.Subtask::get).toList();
}
}
If enrichOrder fails for any order, the inner scope cancels the other enrichments. If the inner scope fails, the outer scope cancels fetchUser.
Summary
Structured concurrency gives you:
- Automatic cancellation — if one subtask fails, others are cancelled
- No thread leaks — scope cleanup guarantees all threads finish
- Clean error propagation —
throwIfFailed()surfaces the first error - Timeout support —
joinUntil()for deadline-based execution - Readable code — fork, join, get — no callback chains
Use ShutdownOnFailure when all results are required. Use ShutdownOnSuccess for first-wins races. Combine with virtual threads for lightweight, scalable concurrency.