JEP 505, Structured Concurrency (Fifth Preview), has reached Targeted status in the JDK 25 release. The API, which has evolved through five preview iterations, aims to simplify and provide developers with clearer, safer frameworks for managing parallel tasks, particularly when working with virtual threads. The latest preview refines the API introduced in earlier incubations (JEP 428, JEP 437) and previews (JEP 453, JEP 462, JEP 480, JEP 499). Most notably, StructuredTaskScope
is no longer instantiated via public constructors. Developers now open a scope through static factory methods, such as StructuredTaskScope.open()
, a change that clarifies defaults and paves the way for richer completion policies.
According to the JEP specification, structured concurrency addresses three fundamental concerns in parallel programming:
- Keeping subtask lifetimes strictly confined to a well-defined parent scope
- Implementing reliable cancellation that prevents resource leaks
- Enhancing observability through structured thread hierarchies
The API centers around the java.util.concurrent.StructuredTaskScope
class, which manages a group of concurrent subtasks. Developers fork subtasks within a scope and then join them together, with the scope automatically managing their execution boundaries.
For example, consider the following coding snippets:
try (var scope = StructuredTaskScope.open()) {
Subtask<String> user = scope.fork(() -> fetchUser(userId));
Subtask<List<Order>> orders = scope.fork(() -> fetchOrders(userId));
scope.join(); // Wait for all subtasks
// Process results or handle exceptions
String userName = user.get();
List<Order> userOrders = orders.get();
}
This example demonstrates the fundamental usage pattern of structured concurrency. It creates a scope using the new factory method, forks two concurrently executed tasks (fetching user data and orders), waits for both to complete with join(), and then retrieves their results. The scope guarantees that both subtasks are either completed or cancelled when execution leaves the block.
For contrast, here’s how the same code would have looked in previous previews of the Structured Concurrency API:
try (var scope = new StructuredTaskScope<>()) {
Subtask<String> user = scope.fork(() -> fetchUser(userId));
Subtask<List<Order>> orders = scope.fork(() -> fetchOrders(userId));
scope.join();
// Process results or handle exceptions
String userName = user.get();
List<Order> userOrders = orders.get();
}
While the basic structure remains similar, the fifth preview introduces factory methods like StructuredTaskScope.open()
replaces the constructor-based instantiation. This change improves API readability and gives library maintainers more flexibility for future evolution without breaking compatibility.
The zero-argument open()
factory creates a scope that fails fast: if any subtask throws, the remaining ones are interrupted and join()
rethrows. Developers can supply custom policies via open(Joiner)
, for example:
// Return the first successful result, cancel the rest
<T> T race(Collection<Callable<T>> tasks) throws InterruptedException {
try (var scope = StructuredTaskScope.open(
Joiner.<T>anySuccessfulResultOrThrow())) {
tasks.forEach(scope::fork);
return scope.join();
}
}
Each fork launches a subtask, by default on a virtual thread, returning a Subtask
handle whose get()
is safe only after join()
completes. The scope enforces structure: calls to fork or join from non-owner threads, or exiting the block without closing, raise StructureViolationException
.
The factory method allSuccessfulOrThrow()
returns a new joiner that, when all subtasks complete successfully, yields a stream of the subtasks:
<T> List<T> runConcurrently(Collection<Callable<T>> tasks) throws InterruptedException {
try (var scope = StructuredTaskScope.open(Joiner.<T>allSuccessfulOrThrow())) {
tasks.forEach(scope::fork);
return scope.join().map(Subtask::get).toList();
}
}
If one or more subtasks fail, then join()
throws a FailedException
, with the exception from one of the failed subtasks as its cause.
The Joiner
interface declares three additional factory methods. The awaitAll()
method returns a new joiner that waits for all subtasks to complete, whether successfully or not. The awaitAllSuccessfulOrThrow()
method returns a new joiner that waits for all subtasks to complete successfully. Lastly, allUntil(Predicate<Subtask<? extends T>> isDone)
returns a new joiner that, when all subtasks complete successfully or else a predicate on a completed subtask returns true, cancels the enclosing scope and yields a stream of all the subtasks.
When using any Joiner
, it is critical to create a new Joiner for each StructuredTaskScope
. Joiner objects should never be used in different task scopes or reused after a scope is closed.
The Joiner
interface can be implemented directly to support custom completion policies. It has two type parameters: T
for the result type of the subtasks executed in the scope, and R
for the result type of the join()
method. The interface looks as follows:
public interface Joiner<T, R> {
public default boolean onFork(Subtask<? extends T> subtask);
public default boolean onComplete(Subtask<? extends T> subtask);
public R result() throws Throwable;
}
The onFork()
method is invoked when forking a subtask, while the onComplete()
method is invoked when a subtask completes.
The specification also clarifies that a scope’s subtasks inherit ScopedValue
bindings. If a scope’s owner reads a value from a bound ScopedValue
then each subtask will read the same value.
This JEP also extends the JSON thread-dump format added for virtual threads to show how StructuredTaskScopes
group threads into a hierarchy:
$jcmd <pid> Thread.dump_to_file -format=json <file>
The JSON object for each scope contains an array of the threads forked in the scope, together with their stack traces.
As a preview feature, the OpenJDK team encourages developers to experiment with this fifth iteration in JDK 25 and provide feedback. This input is vital for maturing the API.