PCSalt
YouTube GitHub
Back to Java
Java · 2 min read

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:

  1. fork() starts each subtask in a virtual thread
  2. join() waits for all subtasks to complete
  3. throwIfFailed() throws if any subtask failed
  4. If one fails, ShutdownOnFailure cancels the other
  5. try-with-resources ensures 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
}
AspectCompletableFutureStructuredTaskScope
CancellationManualAutomatic on failure
Thread leak preventionYour responsibilityBuilt-in
Error propagationComplex chainingthrowIfFailed()
TimeoutorTimeout() per futurejoinUntil() for all
ReadabilityCallback chainsLinear 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 propagationthrowIfFailed() surfaces the first error
  • Timeout supportjoinUntil() 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.