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:
- Create a StructuredTaskScope with try-with-resources
- Define subtasks as Callable instances
- Fork each subtask in its own thread
- Join to wait for completion
- 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
- Structured Concurrency – Oracle Java Docs
- JEP 453: Structured Concurrency (Preview)
- JEP 480: Structured Concurrency (Third Preview)
- JEP 499: Structured Concurrency (Fourth Preview)
- JEP 505: Structured Concurrency (Fifth Preview)
Tutorials & Deep Dives
- Project Loom: Structured Concurrency – Rock the JVM
- Project Loom – Structured Concurrency – Inside.java
- Programmer-Friendly Structured Concurrency – SoftwareMill
- What’s New in JDK 23 – Dan Vega
Module System and Reflection
- Reflective Access with Open Modules – Dev.java
- Qualified Exports and Opens – Dev.java
- Reflection vs Encapsulation – Sitepoint
- Handling Reflection with Java Modules – Medium
- Java 9 Modules – Reflective Access
- Java Reflection – Modules – Jenkov
Advanced Topics
Training & Courses
- Java Concurrency Aficionados Bundle – JavaSpecialists
- Mastering Virtual Threads Course – JavaSpecialists

