Part 8 of 12

Structured Concurrency (JEP 505): Preview 5 — What Changed?

Note: JEP 505 is a preview feature in Java 25 (5th preview). Enable with --enable-preview. The API has been stable for several rounds and is expected to finalize in Java 26.


The Problem with Unstructured Concurrency

Classic Java concurrency with ExecutorService is unstructured: you submit tasks, and those tasks have no formal relationship with the code that submitted them. This causes three recurring problems:

Problem 1: Partial failure leaves orphaned tasks

ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor();

Future<User>   userFuture   = exec.submit(() -> fetchUser(userId));
Future<Orders> ordersFuture = exec.submit(() -> fetchOrders(userId));

User   user   = userFuture.get();    // blocks here
Orders orders = ordersFuture.get();  // also blocks

If fetchUser throws, ordersFuture is never cancelled. It keeps running in the background, consuming resources for a request that already failed.

Problem 2: Cancellation doesn’t propagate

If the outer thread is interrupted (e.g., the HTTP request times out), userFuture and ordersFuture are not automatically cancelled. You need to track and cancel them manually.

Problem 3: Stack traces are useless

When a subtask throws, the stack trace shows the thread pool thread — not the logical call chain. Debugging concurrent failures is a nightmare.


Structured Concurrency: The Core Idea

Structured Concurrency treats concurrent subtasks like a block of code: they must complete before the enclosing block exits. This mirrors structured programming’s rule that control flow must enter and exit a block in a well-defined order.

The API: StructuredTaskScope

try (var scope = new StructuredTaskScope<T>()) {
    var task1 = scope.fork(() -> ...);
    var task2 = scope.fork(() -> ...);
    scope.join();        // wait for all tasks
    // use task1.get(), task2.get()
}
// scope.close() is called automatically — all tasks are done or cancelled

Guarantee: when you exit the try block (normally or via exception), all forked tasks have either completed or been cancelled. No orphans.


ShutdownOnFailure: Any Failure Cancels All

The most common pattern: run multiple tasks in parallel; if any one fails, cancel the rest immediately.

import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.ShutdownOnFailure;

record UserProfile(User user, List<Order> orders, List<Product> recommendations) {}

public UserProfile loadUserDashboard(String userId) throws Exception {
    try (var scope = new ShutdownOnFailure()) {

        var userTask    = scope.fork(() -> fetchUser(userId));
        var ordersTask  = scope.fork(() -> fetchOrders(userId));
        var recoTask    = scope.fork(() -> fetchRecommendations(userId));

        scope.join()            // wait for all three
             .throwIfFailed();  // re-throw if any failed (cancels others automatically)

        return new UserProfile(
            userTask.get(),
            ordersTask.get(),
            recoTask.get()
        );
    }
}

If fetchOrders throws a DatabaseException, fetchUser and fetchRecommendations are cancelled immediately. The throwIfFailed() call re-throws the original exception. The caller gets a clean failure with a useful stack trace.


ShutdownOnSuccess: First Result Wins

Use this when you have multiple providers and want the first successful result:

import java.util.concurrent.StructuredTaskScope.ShutdownOnSuccess;

public String resolveConfig(String key) throws Exception {
    try (var scope = new ShutdownOnSuccess<String>()) {

        scope.fork(() -> fetchFromVault(key));       // HashiCorp Vault
        scope.fork(() -> fetchFromConfigServer(key)); // Spring Cloud Config
        scope.fork(() -> fetchFromEnv(key));          // environment variable

        scope.join();
        return scope.result();   // returns the first non-exceptional result
    }
}

The first task to return a value causes all others to be cancelled. You don’t wait for the slowest provider.


Nested Scopes and Scope Inheritance

Structured concurrency scopes can be nested. Inner scopes must complete before outer scopes:

public ReportData buildReport(String reportId) throws Exception {
    try (var outerScope = new ShutdownOnFailure()) {

        var headerTask = outerScope.fork(() -> fetchHeader(reportId));

        var bodyTask = outerScope.fork(() -> {
            // Inner scope — its tasks are children of this fork
            try (var innerScope = new ShutdownOnFailure()) {
                var section1 = innerScope.fork(() -> fetchSection(reportId, 1));
                var section2 = innerScope.fork(() -> fetchSection(reportId, 2));
                var section3 = innerScope.fork(() -> fetchSection(reportId, 3));

                innerScope.join().throwIfFailed();

                return new Body(section1.get(), section2.get(), section3.get());
            }
        });

        outerScope.join().throwIfFailed();

        return new ReportData(headerTask.get(), bodyTask.get());
    }
}

If fetchSection(reportId, 2) fails, section1 and section3 are cancelled, the inner scope re-throws, the outer bodyTask fails, headerTask is cancelled, and the outer scope re-throws. Clean failure propagation at every level.


Deadline / Timeout

Set a timeout on the entire scope:

public String fetchWithTimeout(String url) throws Exception {
    try (var scope = new ShutdownOnFailure()) {
        var task = scope.fork(() -> httpGet(url));

        // Cancel all tasks if not done within 2 seconds
        scope.joinUntil(Instant.now().plusSeconds(2));
        scope.throwIfFailed();

        return task.get();
    }
}

If the 2-second deadline passes, all forked tasks are cancelled and joinUntil throws TimeoutException.


Real-World Example: Parallel API Aggregation

A common microservices pattern: aggregate data from three downstream services to build a single API response.

import module java.base;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.ShutdownOnFailure;

record ProductPage(Product product, List<Review> reviews, PriceInfo pricing) {}

public class ProductService {

    public ProductPage getProductPage(String productId) throws Exception {
        try (var scope = new ShutdownOnFailure()) {

            var productTask = scope.fork(() ->
                catalogClient.getProduct(productId)
            );
            var reviewsTask = scope.fork(() ->
                reviewClient.getReviews(productId)
            );
            var pricingTask = scope.fork(() ->
                pricingClient.getPricing(productId)
            );

            // Wait up to 500ms for all three
            scope.joinUntil(Instant.now().plusMillis(500));
            scope.throwIfFailed();

            return new ProductPage(
                productTask.get(),
                reviewsTask.get(),
                pricingTask.get()
            );
        }
    }
}

All three calls run in parallel on virtual threads. Total latency = max(catalog, reviews, pricing) — not the sum. With 100ms per service, you get a 100ms response instead of 300ms.


What Changed in the Java 25 Preview?

The Java 25 (5th preview) version of StructuredTaskScope is largely API-stable compared to Java 21. The main evolution has been:

ChangeDetails
Joiner API (Java 24+)Extensible join policy via StructuredTaskScope.Joiner<T,R> — lets you build custom aggregation strategies beyond ShutdownOnFailure / ShutdownOnSuccess
scope.fork() return typeNow returns StructuredTaskScope.Subtask<T> instead of Future<T> — cleaner API, Subtask.get() doesn’t throw checked exceptions
ScopedValue integrationForked tasks automatically inherit the parent’s ScopedValue bindings

Custom Joiner: Collect All Results

With the Joiner API (Java 24+), you can aggregate all results — not just the first or throw on any:

public List<SearchResult> searchAll(String query) throws Exception {
    try (var scope = StructuredTaskScope.open(Joiner.allSuccessfulOrThrow())) {

        scope.fork(() -> searchDatabase(query));
        scope.fork(() -> searchElasticsearch(query));
        scope.fork(() -> searchCache(query));

        return scope.join()
                    .results()
                    .toList();  // returns results from all successful tasks
    }
}

Summary

Structured Concurrency solves the three classic problems of unstructured concurrency:

ProblemUnstructuredStructured
Orphaned tasks on failureTasks keep runningAuto-cancelled
Cancellation propagationManual Future.cancel()Automatic
Stack tracesThread pool threadLogical call chain
TimeoutManual Future.get(timeout) per taskjoinUntil(instant) on the scope

It is still in preview in Java 25 — but after 5 rounds, the API is stable enough to use in non-public-API code today.


Next up: Compact Object Headers →