Structured Concurrency
Structured concurrency treats groups of related tasks running in different threads as a single unit of work, thereby streamlining error handling and cancellation, improving reliability, and enhancing observability.
With structured concurrency, a task (a unit of work) is split into several concurrent subtasks. These subtasks must complete before the task continues. Subtasks are grouped within a scope, which is represented by the StructuredTaskScope class in the java.util.concurrent package. To run a subtask within a scope, you fork it, which executes a value-returning method. By default, this starts a new virtual thread in the scope, which runs the subtask. After you've forked your subtasks, you join them by calling the StructuredTaskScope::join method. As a result, the scope waits for all the forked subtasks to complete. By default, the join method returns null if all the subtasks complete successfully; otherwise, it throws an exception. This is a scope's default policy. You can specify a different policy by specifying a joiner. For example, there's a joiner that returns a stream of all subtasks if they have all completed successfully.
A subtask can create its own scope to fork its own subtasks, thus
creating a hierarchy of scopes. The lifetime of a subtask is confined to the
lifetime of its containing scope; all of a subtask's threads are guaranteed
to have terminated once its scope is closed. You can observe this hierarchy
of scopes by generating a thread dump with the jcmd
command.
Note:
This is a preview feature. A preview feature is a feature whose design, specification, and implementation are complete, but is not permanent. A preview feature may exist in a different form or not at all in future Java SE releases. To compile and run code that contains preview features, you must specify additional command-line options. See Preview Language and VM Features.For background information about structured concurrency, see JEP 505.
Basic Usage of the StructuredTaskScope Class
To use the StructuredTaskScope class, you follow these general steps:
- Open a new StructuredTaskScope by calling
one of its static open methods in a
try
-with-resources statement. The thread that opens the scope is the scope's owner. - Define your subtasks as instances of Callable or Runnable.
- Within the
try
block, fork each subtask in its own thread with StructuredTaskScope::fork. - Call StructuredTaskScope::join to join all of the scope's subtasks as a unit. As a result, the StructuredTaskScope waits for all the subtasks to complete and then returns the result, which may throw an exception.
- Handle the result of StructuredTaskScope::join.
- Close the scope, usually implicitly through the
try
-with-resources statement. This cancels the scope, if it's not already canceled. This prevents new threads from starting in the scope and interrupts threads running unfinished subtasks.
The following figure illustrates these steps. Notice that the StructuredTaskScope must wait for all subtasks to finish execution because of the join() method.
Figure 14-2 Using the StructuredTaskScope Class

In general, code that use the StructuredTaskScope class has the following structure:
Callable<String> task1 = () -> { return "Hello World"; };
Callable<Integer> task2 = () -> { return Interlingual(42); };
// Open a new StructuredTaskScope
try (var scope = StructuredTaskScope.open()) {
// Fork subtasks
Subtask<String> subtask1 = scope.fork(task1);
Subtask<Integer> subtask2 = scope.fork(task2);
// Join the scope's subtasks and propagate exceptions
scope.join();
// Process the join method's results
System.out.println("subtask1: " + subtask1.get());
System.out.println("subtask2: " + subtask2.get());
} catch (InterruptedException e) {
System.out.println("InterruptedException");
}
The zero-parameter open() factory method
creates and opens a StructuredTaskScope that implements
the default policy, which is to return null
if all subtasks complete
successfully or throw a StructuredTaskScope.FailedException if any subtask fails. You can specify
another policy by calling one of the open factory methods
that takes a StructuredTaskSope.Joiner as a
parameter.
To start a subtask, call the fork(Callable) or fork(Runnable) method. This starts a thread to run a subtask, which by default is a virtual thread.
The scope's owner thread must call the join method from within the scope. The join method waits for all subtasks started in this scope to complete or the scope to be canceled. According to the default policy, if any subtask fails, then the join method throws an exception, and the scope is canceled. If all subtasks succeed, then the join method completes normally and returns null. If you open a StructuredTaskScope with a Joiner, then the join method can return a different type of value.
If a scope's block exits before joining, then the scope is canceled, and the owner will wait in its close method for all subtasks to terminate before throwing an exception.
After joining, the scope's owner can process the results of the subtasks by using the Subtask objects returned from the fork methods. For example, call the Subtask::get method to obtain the result of a successfully completed subtask. Note that this method throws an exception if it's called before joining.
Joiners
A joiner is an object used with a StructuredTaskScope to handle subtask completion and produce the result for the scope owner waiting in the join method for subtasks to complete. Depending on the joiner, the join method may return a result, a stream of elements, or some other object.
The StructuredTaskScope.Joiner interface defines the following static methods that create joiners for commonly used policies:
Table 14-2 Static Methods for Policies
Static Method | Result of the StructuredTaskScope::join Method |
---|---|
allSuccessfulOrThrow |
|
anySuccessfulResultOrThrow |
|
awaitAllSuccessfulorThrow |
|
awaitAll |
|
The following example opens a StructuredTaskScope with a joiner returned by allSuccessfulOrThrow. The StructuredTaskScope forks five subtasks, each of which runs randomTask. The randomTask method takes a maximum duration and a threshold as parameters. The randomTask method randomly generates a duration. If this duration is greater than the threshold, it throws a TooSlowException.
class TooSlowException extends Exception {
public TooSlowException(String s) {
super(s);
}
}
Integer randomTask(int maxDuration, int threshold) throws InterruptedException, TooSlowException {
int t = new Random().nextInt(maxDuration);
if (t > threshold) {
throw new TooSlowException("Duration " + t + " greater than threshold " + threshold);
}
Thread.sleep(t);
System.out.println("Duration: " + t);
return Integer.valueOf(t);
}
void runConcurrentlyRandomTasks() {
List<Callable<Integer>> subtasks = IntStream.range(0, 5)
.mapToObj(i -> (Callable<Integer>) () -> randomTask(1000, 900))
.toList();
try (var scope = StructuredTaskScope.open(Joiner.<Integer>allSuccessfulOrThrow())) {
subtasks.forEach(scope::fork);
scope.join().forEach(e -> System.out.println("Result: " + e.get()));
} catch (InterruptedException e) {
System.out.println("InterruptedException");
} catch (StructuredTaskScope.FailedException e) {
Throwable cause = e.getCause();
System.out.println("FailedException: " + cause.getClass().getSimpleName() + ": " + cause.getMessage());
}
}
The example prints outputs similar to the following if all five subtasks don't throw any exceptions:
Duration: 312
Duration: 635
Duration: 672
Duration: 816
Duration: 891
Result: 635
Result: 891
Result: 672
Result: 816
Result: 312
The example prints output similar to the following if one subtask throws an exception:
FailedException: TooSlowException: Duration 966 greater than threshold 900
In this example, because the StructuredTaskScope was opened with a joiner returned by the allSuccessfulOrThrow method, its join method returns a stream of the subtasks (if all the subtasks complete successfully).
Tip:
If you want to fork a series of subtasks of the same type, you can use the following pattern:<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();
}
}
How you handle the value that StructuredTaskScope::join returns depends on the joiner. For example, the join method of a StructuredTaskScope opened with a joiner returned by the anySuccessfulResultOrThrow method returns the result of the first successful subtask:
<T> T race(Collection<Callable<T>> tasks) throws InterruptedException {
try (var scope = StructuredTaskScope.open(Joiner.<T>anySuccessfulResultOrThrow())) {
tasks.forEach(scope::fork);
return scope.join();
}
}
Custom Joiners
You can create your own custom joiner by implementing the StructuredTaskScope.Joiner<T,R> interface:
public static 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 parameter T
is the result type of the subtasks run in the scope, and
R
is the result type of the join method.
The onFork method is invoked when forking a subtask. The onComplete method is invoked with a subtask completes.
The onFork and onComplete methods return a boolean value, which indicates if the scope should be canceled.
The result method is invoked to either produce the result for the join method once all subtasks have completed or throw an exception if the scope is canceled.
The following example, CollectingJoiner
, is a joiner that
collects the results of subtasks that complete successfully and ignores the subtasks
that fail.
class CollectingJoiner<T> implements Joiner<T, Stream<T>> {
private final Queue<T> results = new ConcurrentLinkedQueue<>();
public boolean onComplete(Subtask<? extends T> subtask) {
if (subtask.state() == Subtask.State.SUCCESS) {
results.add(subtask.get());
}
return false;
}
public Stream<T> result() {
return results.stream();
}
}
Note that the onComplete method may be invoked by several
threads concurrently; consequently CollectingJoiner
is thread-safe; it
stores the results of successful subtasks in a ConccurrentLinkedQueue. The method Subtask::state can
return one of the following values of type
StructuredTaskScope.Subtask.State:
- FAILED: The subtask failed with an exception.
- SUCCESS: The subtask completed successfully.
- UNAVAILABLE: The subtask result or exception is not available. This state indicates that the subtask was forked but has not completed, it completed after the scope was canceled, or it was forked after the scoped was canceled.
The result method returns a stream of successful subtask results.
The following example uses this custom policy:
<T> List<T> allSuccessful(List<Callable<T>> tasks) throws InterruptedException {
try (var scope = StructuredTaskScope.open(new CollectingJoiner<T>())) {
tasks.forEach(scope::fork);
return scope.join().toList();
}
}
void testCollectingJoiner() {
List<Callable<Integer>> subtasks = IntStream
.range(0, 10)
.mapToObj(i -> (Callable<Integer>) () -> randomTask(1000, 300))
.collect(Collectors.toList());
try {
allSuccessful(subtasks).stream().forEach(r -> System.out.println("Result: " + r));
} catch (InterruptedException e) {
Throwable cause = e.getCause();
System.out.println("FailedException: " + cause.getClass().getSimpleName() + ": " + cause.getMessage());
}
}
It prints output similar to the following:
Duration: 122
Duration: 238
Result: 122
Result: 238
Configuring StructuredTaskScope
One of the StructuredTaskScope::open methods, in addition to a Joiner, accepts a StructuredTaskScope.Config object as a parameter. This object enables you to:
- Set the scope's name for monitoring and management purposes
- Set the scope's timeout
- Set the thread factory that the scope's fork methods use to create threads
The following example opens a StructuredTaskScope with a configuration object that specifies a timeout of 200 ms, which means that a TimeoutException will be thrown if the subtasks forked in the StructuredTaskScope don't complete within 200 ms:
void runConcurrentlyConfiguredRandomTasks() {
var subtasks = IntStream.range(0, 5)
.mapToObj(i -> (Callable<Integer>) () -> randomTask(1000, 900))
.collect(Collectors.toList());
try (var scope = StructuredTaskScope.open(Joiner.<Integer>allSuccessfulOrThrow(),
cf -> cf.withTimeout(Duration.ofMillis(200)))) {
subtasks.forEach(scope::fork);
Stream<Subtask<Integer>> s = scope.join();
s.forEach(r -> System.out.println("Result: " + r.get()));
} catch (InterruptedException e) {
System.out.println("InterruptedException");
} catch (StructuredTaskScope.TimeoutException e) {
System.out.println("TimeoutException");
} catch (StructuredTaskScope.FailedException e) {
Throwable cause = e.getCause();
System.out.println("FailedException: " + cause.getClass().getSimpleName() + ": " + cause.getMessage());
}
}
Scope Hierarchies and Observability
A Subtask can create its own StructuredTaskScope to fork its own subtasks, thus creating a
hierarchy of scopes. The lifetime of a subtask is confined to the lifetime of its containing
scope; all of a subtask's threads are guaranteed to have terminated once its scope is
closed. You can observe this hierarchy of scopes by generating a thread dump with the
jcmd
command.
The following example has three scopes named
RandomTaskScope
, RandomTaskScopeInsideSubtask
, and
RandomTaskSubscope
. The scope named
RandomTaskScopeInsideSubtask
is a scope that has been opened within
a subtask. The scope named RandomTaskSubscope
is a scope opened within
the scope named RandomTaskScope
. These three scopes are opened with a
StructuredTaskScope.Config object that specifies
their name and a thread factory. This thread factory creates a virtual thread with a
unique name. The jcmd
command uses these scope and virtual thread names
when generating a thread dump.
public class SCObservable {
ThreadFactory factory = Thread.ofVirtual().name("RandomTask-", 0).factory();
static String sleepOneSecond(String s) throws InterruptedException {
long pid = ProcessHandle.current().pid();
String threadName = null;
for (int i = 0; i < 20; i++) {
threadName = Thread.currentThread().getName();
System.out.println("PID: " + pid + ", name: " + s + ", thread name: " + Thread.currentThread().getName());
Thread.sleep(1000);
}
return threadName;
}
void handle() throws InterruptedException {
try (var scope = StructuredTaskScope.open(StructuredTaskScope.Joiner.<String>allSuccessfulOrThrow(),
cf -> cf.withThreadFactory(factory)
.withName("RandomTaskScope"))) {
Supplier<String> task0 = scope.fork(() -> sleepOneSecond("task0"));
Supplier<String> task1 = scope.fork(() -> sleepOneSecond("task1"));
Callable<String> t = () -> {
String results = "Result in RandomTaskScopeInsideSubtask: ";
try (var subtaskscope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<String>allSuccessfulOrThrow(),
cf -> cf.withThreadFactory(factory)
.withName("RandomTaskScopeInsideSubtask"))) {
Supplier<String> task2a = subtaskscope.fork(() -> sleepOneSecond("task2a"));
Supplier<String> task2b = subtaskscope.fork(() -> sleepOneSecond("task2b"));
results += subtaskscope.join().map(Subtask::get).collect(Collectors.joining(", "));
}
return results;
};
Supplier<String> task2 = scope.fork(t);
try (var childscope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<String>allSuccessfulOrThrow(),
cf -> cf.withThreadFactory(factory)
.withName("RamdomTaskSubscope"))) {
Supplier<String> task2a = childscope.fork(() -> sleepOneSecond("task3a"));
Supplier<String> task2b = childscope.fork(() -> sleepOneSecond("task3b"));
childscope.join().forEach(r -> System.out.println("Result in RamdomTaskSubscope: " + r.get()));
}
scope.join().forEach(r -> System.out.println("Result in RandomTaskScope: " + r.get()));
}
}
public static void main(String[] args) {
try {
var myApp = new SCObservable();
myApp.handle();
} catch (Exception e) {
e.printStackTrace();
}
}
}
It prints output similar to the following:
PID: 13560, name: task3b, thread name: RandomTask-5
PID: 13560, name: task0, thread name: RandomTask-0
PID: 13560, name: task1, thread name: RandomTask-1
PID: 13560, name: task2b, thread name: RandomTask-6
PID: 13560, name: task3a, thread name: RandomTask-3
PID: 13560, name: task2a, thread name: RandomTask-4
PID: 13560, name: task0, thread name: RandomTask-0
PID: 13560, name: task1, thread name: RandomTask-1
PID: 13560, name: task3b, thread name: RandomTask-5
PID: 13560, name: task2a, thread name: RandomTask-4
PID: 13560, name: task2b, thread name: RandomTask-6
PID: 13560, name: task3a, thread name: RandomTask-3
...
Result in RamdomTaskSubscope: RandomTask-3
Result in RamdomTaskSubscope: RandomTask-5
Result in RandomTaskScope: RandomTask-0
Result in RandomTaskScope: RandomTask-1
Result in RandomTaskScope: Result in RandomTaskScopeInsideSubtask: RandomTask-4, RandomTask-6
While the example SCObservable
is running, you can create a thread dump
in JSON format by running the following command:
jcmd <PID> Thread.dump_to_file -format=json <file>
The thread dump looks similar to the following. To better illustrate the subtask hierarchy of this example, only information pertaining to the names of virtual threads and the JSON objects representing the scopes have been included:
{
"threadDump": {
"processId": "13560",
"time": "2025-06-27T20:31:26.138549300Z",
"runtimeVersion": "25-ea+27-LTS-3363",
"threadContainers": [
{
"container": "<root>", "parent": null, "owner": null,
"threads": [
{ "tid": "3", "name": "main" },
... other threads omitted ...
],
"threadCount": "8"
},
... ForkJoinPool containers omitted ...
{
"container": "RandomTaskScope\/jdk.internal.misc.ThreadFlock$ThreadContainerImpl@44c794fd",
"parent": "<root>",
"owner": "3",
"threads": [
{ "tid": "36", "virtual": true, "name": "RandomTask-0" },
{ "tid": "38", "virtual": true, "name": "RandomTask-1" },
{ "tid": "41", "virtual": true, "name": "RandomTask-2" }
],
"threadCount": "3"
},
{
"container": "RamdomTaskSubscope\/jdk.internal.misc.ThreadFlock$ThreadContainerImpl@44af33ca",
"parent": "RandomTaskScope\/jdk.internal.misc.ThreadFlock$ThreadContainerImpl@44c794fd",
"owner": "3",
"threads": [
{ "tid": "43", "virtual": true, "name": "RandomTask-3" },
{ "tid": "46", "virtual": true, "name": "RandomTask-5" }
],
"threadCount": "2"
},
{
"container": "RandomTaskScopeInsideSubtask\/jdk.internal.misc.ThreadFlock$ThreadContainerImpl@3c3727de",
"parent": "RandomTaskScope\/jdk.internal.misc.ThreadFlock$ThreadContainerImpl@44c794fd",
"owner": "41",
"threads": [
{ "tid": "48", "virtual": true, "name": "RandomTask-6" },
{ "tid": "44", "virtual": true, "name": "RandomTask-4" }
],
"threadCount": "2"
}
]
}
}
The JSON object for each scope contains an array of the threads forked in the scope. The JSON object for a scope also has a reference to its parent so that the structure of the program can be reconstituted from the thread dump.