Java’s concurrency model has been functional for decades. ExecutorService, Future, CompletableFuture — these tools work. They also have a persistent problem: when you submit tasks to an executor, the relationship between the spawning thread and the spawned tasks is not represented in the code. Errors are easy to lose. Cancellation doesn’t propagate automatically. Resources leak when tasks outlive their intended scope.
Structured concurrency, finalized in Java 24 (JEP 505 after multiple preview rounds), fixes this. The core principle: concurrent tasks should have lifetimes that are scoped to the block of code that spawned them, just as try-with-resources scopes resource lifetimes.
The Problem With Unstructured Concurrency
Consider a service that needs to fetch a user and their orders concurrently:
// Unstructured — what happens on errors or cancellation?
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<User> userFuture = executor.submit(() -> fetchUser(userId));
Future<List<Order>> ordersFuture = executor.submit(() -> fetchOrders(userId));
try {
User user = userFuture.get(5, SECONDS);
List<Order> orders = ordersFuture.get(5, SECONDS);
return new UserProfile(user, orders);
} catch (ExecutionException e) {
// Which task failed? What about the other task?
// It's still running. We're leaking it.
throw new RuntimeException(e.getCause());
} catch (TimeoutException e) {
// We timed out waiting — but both tasks are still running.
// We have no clean way to cancel them.
throw new ServiceException("Timed out");
}
Problems:
- If
fetchUserfails,fetchOrderskeeps running until it finishes or we explicitly cancel it - If
fetchOrdersfails afterfetchUsersucceeds, we’ve done wasted work - If the calling thread is interrupted (request cancelled), both tasks keep running
- Error handling is verbose and error-prone
StructuredTaskScope: The Solution
StructuredTaskScope from java.util.concurrent provides structured concurrent execution:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> userTask = scope.fork(() -> fetchUser(userId));
Subtask<List<Order>> ordersTask = scope.fork(() -> fetchOrders(userId));
scope.join(); // Wait for both tasks to complete
scope.throwIfFailed(); // Rethrow any exception from a failed task
return new UserProfile(userTask.get(), ordersTask.get());
}
// When the try block exits:
// - All forked tasks are guaranteed to be complete
// - Any running tasks are cancelled
// - Resources are released
The try-with-resources block is the scope boundary. When execution leaves the block, all tasks forked within it are either completed or cancelled. No leaks.
The Built-In Policies
Two standard StructuredTaskScope implementations:
ShutdownOnFailure
If any forked task fails with an exception, the scope is shut down — remaining tasks are cancelled.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> user = scope.fork(() -> fetchUser(userId));
Subtask<List<Order>> orders = scope.fork(() -> fetchOrders(userId));
Subtask<Preferences> prefs = scope.fork(() -> fetchPreferences(userId));
scope.join().throwIfFailed(); // If any fails, others are cancelled
return new Dashboard(user.get(), orders.get(), prefs.get());
}
If fetchOrders throws an exception, the scope shuts down, fetchPreferences is cancelled, and throwIfFailed() rethrows the exception from fetchOrders.
ShutdownOnSuccess
Returns when any task succeeds. Remaining tasks are cancelled. Useful for “try multiple sources, return the first result”:
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> fetchFromPrimaryCache(key));
scope.fork(() -> fetchFromSecondaryCache(key));
scope.fork(() -> fetchFromDatabase(key));
scope.join(); // Returns when any task succeeds
return scope.result(); // Returns the first successful result
}
Custom Policies
Beyond the built-ins, you can implement custom policies for specific coordination needs:
class CollectResultsScope<T> extends StructuredTaskScope<T> {
private final List<T> results = new CopyOnWriteArrayList<>();
private final List<Throwable> failures = new CopyOnWriteArrayList<>();
@Override
protected void handleComplete(Subtask<? extends T> subtask) {
switch (subtask.state()) {
case SUCCESS -> results.add(subtask.get());
case FAILED -> failures.add(subtask.exception());
default -> {} // cancelled
}
}
List<T> results() { return Collections.unmodifiableList(results); }
List<Throwable> failures() { return Collections.unmodifiableList(failures); }
}
// Usage: collect all results, tolerate some failures
try (var scope = new CollectResultsScope<SearchResult>()) {
searchSources.forEach(source -> scope.fork(() -> source.search(query)));
scope.join();
// Proceed even if some sources failed
return merge(scope.results());
}
Error Propagation and Cancellation
Structured concurrency’s cancellation model is clean:
// Parent task cancellation propagates to children
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<String> task1 = scope.fork(() -> {
// If interrupted, throws InterruptedException
Thread.sleep(Duration.ofSeconds(10));
return "done";
});
scope.join();
// When the calling thread is interrupted while waiting in join(),
// the scope shuts down, task1 receives interrupt, try-block exits cleanly
}
Compare this to CompletableFuture chains where cancellation requires explicit handling at every level.
Nesting Structured Task Scopes
Scopes can be nested, creating a tree of concurrent work with clear lifetime relationships:
try (var outerScope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<UserProfile> profileTask = outerScope.fork(() -> {
// Inner scope — its tasks are scoped to this lambda
try (var innerScope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<User> user = innerScope.fork(() -> fetchUser(userId));
Subtask<Settings> s = innerScope.fork(() -> fetchSettings(userId));
innerScope.join().throwIfFailed();
return new UserProfile(user.get(), s.get());
}
});
Subtask<List<Order>> ordersTask = outerScope.fork(() -> fetchOrders(userId));
outerScope.join().throwIfFailed();
return new Dashboard(profileTask.get(), ordersTask.get());
}
The lifetime hierarchy mirrors the code structure. user and settings are scoped to the inner task. profile and orders are scoped to the outer scope.
Observability With Structured Concurrency
Thread dumps with structured concurrency show task hierarchies rather than a flat list of threads. Tools that understand structured concurrency can display the parent-child relationships between tasks, making debugging concurrent programs significantly easier.
main
└─ StructuredTaskScope
├─ thread-1: fetchUser (running)
├─ thread-2: fetchOrders (blocked on I/O)
└─ thread-3: fetchPreferences (completed)
This is one of the underappreciated benefits: the concurrency structure is visible in the code and in the runtime, not hidden in executor internals.
When to Use It
Structured concurrency is appropriate when:
- You have multiple concurrent tasks that need to coordinate
- Task lifetime should be bounded by a code scope
- Error handling across tasks should be clean and predictable
It’s overkill when:
- Tasks are genuinely fire-and-forget with no coordination needed
- You have long-running background services (these aren’t “scoped” tasks)
- You need dynamic task pools that grow and shrink over time
For most request-scoped concurrent work in web applications — fetching from multiple services, parallel data enrichment, concurrent I/O operations — structured concurrency is the right tool.
Status in Java
Structured concurrency was finalized in Java 24 (JEP 505). If you’re on Java 21 (current LTS as of 2024), it’s available as a preview feature (--enable-preview). Java 25 (LTS, September 2025) includes it as a stable API.
For new projects targeting Java 24+, structured concurrency is worth using over raw ExecutorService for scoped concurrent work. The cleaner error handling, automatic cancellation, and structural clarity are meaningful improvements.