Core Java

Structured Concurrency Patterns in Java

Concurrent programming has long been Java‘s Achilles’ heel. While ExecutorService and Future have served us well, they allow unrestricted patterns where subtasks can outlive their parents, threads leak, and cancellation becomes a nightmare. Structured concurrency changes this by treating groups of related tasks running in different threads as a single unit of work, streamlining error handling and cancellation while improving reliability and observability.

The Problem with Unstructured Concurrency

Consider a typical pattern with ExecutorService: one thread creates the executor, another submits work, and the threads executing tasks have no relationship to either. After a thread submits work, a completely different thread can await the results—any code with a reference to a Future can join it, even code in a thread other than the one which obtained the Future.

This unstructured approach creates real problems. Thread leaks happen when parent tasks fail to properly shut down child tasks. Cancellation delays occur because there’s no coordinated way to signal multiple subtasks. And observability suffers because the relationship between tasks and subtasks isn’t tracked at runtime.

// Unstructured: relationships are implicit and fragile
ExecutorService executor = Executors.newCachedThreadPool();
Future<User> userFuture = executor.submit(() -> fetchUser(id));
Future<Orders> ordersFuture = executor.submit(() -> fetchOrders(id));

// What happens if fetchUser fails? 
// Who's responsible for shutting down the executor?
// Can threads leak if we forget cleanup?

Enter StructuredTaskScope

The principal class of the structured concurrency API is StructuredTaskScope in the java.util.concurrent package, which enables you to coordinate a group of concurrent subtasks as a unit. With StructuredTaskScope, you fork each subtask in its own thread, then join them as a unit, ensuring subtasks complete before the main task continues.

The API follows a clear pattern:

  1. Create a StructuredTaskScope with try-with-resources
  2. Define subtasks as Callable instances
  3. Fork each subtask in its own thread
  4. Join to wait for completion
  5. Handle outcomes from subtasks

Here’s a real-world example fetching weather data:

WeatherReport getWeatherReport(String location) 
        throws ExecutionException, InterruptedException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        Supplier<Temperature> temperature = 
            scope.fork(() -> getTemperature(location));
        Supplier<Humidity> humidity = 
            scope.fork(() -> getHumidity(location));
        Supplier<WindSpeed> windSpeed = 
            scope.fork(() -> getWindSpeed(location));
        
        scope.join()           // Join all subtasks
             .throwIfFailed(); // Propagate errors if any fail
        
        // All succeeded, compose results
        return new WeatherReport(
            location,
            temperature.get(),
            humidity.get(),
            windSpeed.get()
        );
    }
}

The try-with-resources block is crucial—it ensures the scope is properly closed, cancelling any incomplete subtasks and preventing thread leaks.

Short-Circuiting with Shutdown Policies

A short-circuiting pattern encourages subtasks to complete quickly by enabling the main task to interrupt and cancel subtasks whose outcomes are no longer needed. Two built-in policies handle common scenarios:

ShutdownOnFailure: The “Invoke All” Pattern

When you need all subtasks to succeed, ShutdownOnFailure cancels remaining tasks as soon as one fails:

Response handleRequest(String userId) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        Subtask<User> user = scope.fork(() -> fetchUser(userId));
        Subtask<Profile> profile = scope.fork(() -> fetchProfile(userId));
        Subtask<Settings> settings = scope.fork(() -> fetchSettings(userId));
        
        scope.join().throwIfFailed();
        
        // If any failed, we never reach here
        return new Response(user.get(), profile.get(), settings.get());
    }
}

If fetchUser() throws an exception, the scope immediately cancels the profile and settings fetches. No wasted work, no thread leaks.

ShutdownOnSuccess: The “Invoke Any” Pattern

Sometimes you only need the first successful result—think querying multiple data centers or trying fallback services:

String fetchFromMultipleSources(String key) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
        scope.fork(() -> fetchFromPrimaryDB(key));
        scope.fork(() -> fetchFromCache(key));
        scope.fork(() -> fetchFromBackup(key));
        
        scope.join();
        
        // Returns the first successful result
        return scope.result();
    }
}

The moment any subtask succeeds, the scope cancels the others. This pattern is perfect for latency-sensitive operations where you’re racing multiple sources.

Custom Shutdown Policies

In practice, most uses of StructuredTaskScope will not utilize the StructuredTaskScope class directly, but rather use one of the two subclasses that implement shutdown policies or write custom subclasses to implement custom shutdown policies.

Here’s a custom policy that collects all successful results and ignores failures:

class AllSuccessesScope<T> extends StructuredTaskScope<T> {
    private final List<T> results = 
        Collections.synchronizedList(new ArrayList<>());
    
    @Override
    protected void handleComplete(Subtask<? extends T> subtask) {
        if (subtask.state() == Subtask.State.SUCCESS) {
            results.add(subtask.get());
        }
    }
    
    public List<T> getResults() {
        return List.copyOf(results);
    }
}

// Usage
List<Data> collectAll() throws InterruptedException {
    try (var scope = new AllSuccessesScope<Data>()) {
        for (String source : dataSources) {
            scope.fork(() -> fetchData(source));
        }
        scope.join();
        return scope.getResults();
    }
}

Virtual Threads: The Perfect Match

Virtual threads deliver an abundance of threads—structured concurrency can correctly and robustly coordinate them, and enables observability tools to display threads as they are understood by developers. The combination is powerful because virtual threads make it cheap to create millions of threads, while structured concurrency ensures you manage them safely.

// Launching 10,000 concurrent tasks is now practical
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    for (int i = 0; i < 10_000; i++) {
        final int taskId = i;
        scope.fork(() -> processTask(taskId));
    }
    scope.join().throwIfFailed();
}

With platform threads, this would be disastrous. With virtual threads and structured concurrency, it’s straightforward and safe.

Module System Considerations

When building modular applications with structured concurrency, understanding Java’s module system becomes important. With modules, reflection lost its superpowers and is bound to the exact same accessibility rules as compiled code—it can only access public members of public classes in exported packages.

By default, only packages explicitly exported in module-info.java are visible. If you’re using frameworks that rely on reflection (like Spring or Hibernate), you’ll need additional declarations:

module com.example.app {
    // Regular export for compile-time access
    exports com.example.api;
    
    // Opens for runtime reflection access
    opens com.example.entities to org.hibernate.orm.core;
    
    requires java.base;
    requires org.hibernate.orm.core;
}().throwIfFailed();

At compile time, opened packages are fully encapsulated as if the directive weren’t there, but at run time the package’s types are available for reflection, freely interacting with all types and members—public or not.

For full reflection access across all packages, you can declare an open module:

open module com.example.app {
    exports com.example.api;
    requires java.base;
}

An open module opens all packages it contains as if each was used individually in an opens directive, which is convenient but reduces encapsulation.

Observability and Debugging

Structured concurrency dramatically improves observability. Thread dumps now show clear parent-child relationships:

jcmd <pid> Thread.dump_to_file -format=json output.json

The JSON output reveals the StructuredTaskScope with its forked subtasks in an array, making it easy to understand what’s running and why. This is transformative compared to flat thread dumps where relationships were implicit.

Current Status and Evolution

Structured concurrency was proposed by JEP 428 and delivered in JDK 19 as an incubating API, re-incubated in JDK 20, first previewed in JDK 21 via JEP 453, and re-previewed in JDK 22 and 23. As of JDK 25, the API has evolved with static factory methods replacing public constructors.

To use structured concurrency in current JDK versions, enable preview features:

# Compile
javac --release 21 --enable-preview MyApp.java

# Run
java --enable-preview MyApp

The API is stabilizing based on real-world feedback. Structured concurrency has proven to be a safe, expressive, and understandable approach to concurrency, with Python libraries pioneering the field, followed by languages like Kotlin.

Best Practices

Always Use Try-With-Resources: The scope must be closed to prevent thread leaks. Never manage StructuredTaskScope lifecycle manually.

Choose the Right Policy: Use ShutdownOnFailure when all results matter, ShutdownOnSuccess for racing scenarios, or implement custom policies for specific needs.

Combine with Virtual Threads: Structured concurrency shines brightest when paired with virtual threads, enabling massive concurrency with simple code.

Avoid Shared Mutable State: While structured concurrency handles coordination, you’re still responsible for thread safety of shared data.

Consider Scoped Values: For passing context through the task hierarchy, scoped values (JEP 481) provide a better alternative to ThreadLocal.

Real-World Example: Aggregating User Data

Let’s build a complete example aggregating data from multiple sources:

public class UserAggregator {
    record UserData(User user, List<Order> orders, 
                    Stats stats, Recommendations recs) {}
    
    public UserData aggregate(String userId) 
            throws ExecutionException, InterruptedException {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            Supplier<User> user = 
                scope.fork(() -> userService.fetch(userId));
            Supplier<List<Order>> orders = 
                scope.fork(() -> orderService.fetch(userId));
            Supplier<Stats> stats = 
                scope.fork(() -> statsService.compute(userId));
            Supplier<Recommendations> recs = 
                scope.fork(() -> mlService.recommend(userId));
            
            scope.join().throwIfFailed();
            
            return new UserData(
                user.get(),
                orders.get(),
                stats.get(),
                recs.get()
            );
        }
    }
}

This pattern is clean, safe, and efficient. If any service fails, all others are cancelled immediately. The scope ensures proper cleanup. And with virtual threads, this scales to thousands of concurrent requests.

What Developers Are Saying

Java architects decided not to return a Future instance from the fork method to avoid confusion with unstructured computations and give a clear-cut separation from the old concurrency model. This design decision emphasizes that structured concurrency is a new paradigm, not just an incremental improvement.

The Rock the JVM tutorial notes that structured concurrency finally brings to Java what other JVM languages have offered through libraries like Kotlin Coroutines and Scala Cats Effects Fibers, but with official platform support.

Looking Forward

Structured concurrency represents a fundamental shift in how we think about concurrent programming. Instead of managing individual threads and futures, we structure concurrent work hierarchically—just as we structure sequential code with methods and loops.

The benefits are clear: no thread leaks, proper error propagation, coordinated cancellation, and enhanced observability. Combined with virtual threads, Java now offers a concurrency model that’s both powerful and approachable.

As the API moves toward finalization, expect wider adoption in frameworks and libraries. Spring, Hibernate, and other ecosystem projects are already considering how to leverage structured concurrency for cleaner, more reliable concurrent code.

Useful Links

Official Documentation

Tutorials & Deep Dives

Module System and Reflection

Advanced Topics

Training & Courses

Community Resources

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button