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 as a single unit. 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 533.
Basic Usage of the StructuredTaskScope Interface
To use the StructuredTaskScope interface, 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
tryblock, 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 Interface

In general, code that use the StructuredTaskScope interface has the following structure:
public class StructuredTaskScopeExample {
static Integer Interlingual(int value) {
return value;
}
public static void main(String[] args) {
Callable<String> task1 = () -> { return "Hello World"; };
Callable<Integer> task2 = () -> { return Interlingual(42); };
try (var scope = StructuredTaskScope.open()) {
Subtask<String> subtask1 = scope.fork(task1);
Subtask<Integer> subtask2 = scope.fork(task2);
scope.join();
System.out.println("subtask1: " + subtask1.get());
System.out.println("subtask2: " + subtask2.get());
} catch (InterruptedException e) {
System.out.println("InterruptedException");
} catch (ExecutionException e) {
System.out.println("ExecutionException: " + e.getCause());
}
}
}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 an exception if any subtask fails. You can specify another policy by calling one of the open factory methods that takes a StructuredTaskScope.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 list 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(Function), allSuccessfulOrThrow() |
|
| anySuccessfulOrThrow(Function), anySuccessfulOrThrow() |
|
| awaitAllSuccessfulOrThrow(Function), awaitAllSuccessfulOrThrow() |
|
| allUntil(Predicate) |
|
Methods that have a Function parameter are just like their corresponding zero-parameter methods except that join throws an exception produced by the provided Function when the outcome is to throw an exception.
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.
public class AllSuccessfulOrThrowExample {
static class TooSlowException extends Exception {
public TooSlowException(String s) {
super(s);
}
}
static Integer randomTask(int maxDuration, int threshold)
throws InterruptedException, TooSlowException {
int t = new Random().nextInt(maxDuration);
System.out.println("Duration: " + t);
if (t > threshold) {
throw new TooSlowException(
"Duration " + t + " greater than threshold " + threshold);
}
Thread.sleep(t);
return Integer.valueOf(t);
}
static void runConcurrentlyRandomTasks() {
List<Callable<Integer>> subtasks = IntStream.range(0, 5)
.mapToObj(i -> (Callable<Integer>) () -> randomTask(1000, 700))
.toList();
try (var scope = StructuredTaskScope.open(Joiner.<Integer>allSuccessfulOrThrow())) {
subtasks.forEach(scope::fork);
scope.join().forEach(e -> System.out.println("Result: " + e));
} catch (InterruptedException e) {
System.out.println("InterruptedException");
} catch (ExecutionException e) {
Throwable cause = e.getCause();
System.out.println("ExecutionException: "
+ cause.getClass().getSimpleName() + ": " + cause.getMessage());
}
}
public static void main(String[] args) {
runConcurrentlyRandomTasks();
}
}The example prints outputs similar to the following if all five subtasks don't throw any exceptions:
Duration: 471
Duration: 77
Duration: 191
Duration: 31
Duration: 347
Result: 77
Result: 471
Result: 191
Result: 31
Result: 347The example prints output similar to the following if one subtask throws an exception:
Duration: 427
Duration: 177
Duration: 285
Duration: 417
Duration: 895
ExecutionException: TooSlowException: Duration 895 greater than threshold 700In this example, because the StructuredTaskScope was opened with a joiner returned by the allSuccessfulOrThrow method, its join method returns a list 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, ExecutionException {
try (var scope = StructuredTaskScope.open(Joiner.<T>allSuccessfulOrThrow())) {
tasks.forEach(scope::fork);
return scope.join();
}
}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, ExecutionException {
try (var scope = StructuredTaskScope.open(Joiner.<T>anySuccessfulOrThrow())) {
tasks.forEach(scope::fork);
return scope.join();
}
}The following example demonstrates Joiner::allUntil. The StructuredTaskScope forks three subtasks, each of which runs search. The joiner cancels the scope if a subtask is successful and its SearchResult.value is equal or greater than 90:
public class AllUntilExample {
record SearchResult(String name, int value) {}
static SearchResult search(String name, int delayMs, int value) throws InterruptedException {
Thread.sleep(delayMs);
return new SearchResult(name, value);
}
public static void main(String[] args) throws InterruptedException {
List<Callable<SearchResult>> tasks = List.of(
() -> search("engine-A", 800, 60),
() -> search("engine-B", 300, 92),
() -> search("engine-C", 1200, 75)
);
try (var scope = StructuredTaskScope.open(
Joiner.<SearchResult>allUntil(subtask ->
subtask.state() == Subtask.State.SUCCESS &&
subtask.get().value() >= 90))) {
tasks.forEach(scope::fork);
List<Subtask<SearchResult>> subtasks = scope.join();
for (Subtask<SearchResult> subtask : subtasks) {
System.out.print(subtask.state() + " -> ");
if (subtask.state() == Subtask.State.SUCCESS) {
System.out.println(subtask.get());
} else {
System.out.println("(no result)");
}
}
}
}
}It prints the following output:
UNAVAILABLE -> (no result)
SUCCESS -> SearchResult[name=engine-B, value=92]
UNAVAILABLE -> (no result)The subtask with SearchResult.name equal to engine-B completed successfully. However, its SearchResult.value was equal or greater than 92. Consequently, the joiner canceled the scope. This canceled the other two subtasks. As a result, the states for these subtasks are UNAVAILABLE.
Custom Joiners
You can create your own custom joiner by implementing the StructuredTaskScope.Joiner interface:
public static interface Joiner<T, R, R_X extends Throwable> {
public default boolean onFork(Subtask<? extends T> subtask);
public default boolean onComplete(Subtask<? extends T> subtask);
public R result() throws R_X;
public R timeout() throws R_X;
}The parameter T is the result type of the subtasks run in the scope, R is the result type of the join method, and R_X is the type of exception thrown by join.
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 timeout method is invoked if the scope is opened with a timeout and the timeout expires before, or while waiting in, the join method. See Configuring StructuredTaskScope for an example of how to open a scope with a timeout.
In the following example, CollectingJoiner is a joiner that collects the results of subtasks that complete successfully and ignores the subtasks that fail. In addition, if the scope is opened with a timeout, then if a subtask doesn't complete before the timeout expires, a JoinerTimeoutException is thrown:
class JoinerTimeoutException extends RuntimeException {
JoinerTimeoutException(Throwable cause) {
super("Structured task scope timed out", cause);
}
}
class CollectingJoiner<T> implements Joiner<T, List<T>, JoinerTimeoutException> {
private final Queue<T> results = new ConcurrentLinkedQueue<>();
@Override
public boolean onComplete(Subtask<T> subtask) {
if (subtask.state() == Subtask.State.SUCCESS) {
results.add(subtask.get());
}
return false;
}
@Override
public List<T> timeout() {
throw new JoinerTimeoutException(
new StructuredTaskScope.CancelledByTimeoutException());
}
@Override
public List<T> result() {
return List.copyOf(results);
}
}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.
JoinerTimeoutException is declared as a runtime exception. Consequently, the method that invokes join method doesn't need to catch or throw it when a timeout isn't set.
The following example uses this custom policy. It sets a timeout duration of 200 ms:
public class CollectingJoinerExample {
static class TooSlowException extends Exception {
TooSlowException(String s) {
super(s);
}
}
static class JoinerTimeoutException extends RuntimeException {
JoinerTimeoutException(Throwable cause) {
super("Structured task scope timed out", cause);
}
}
static class CollectingJoiner<T> implements Joiner<T, List<T>, JoinerTimeoutException> {
private final Queue<T> results = new ConcurrentLinkedQueue<>();
@Override
public boolean onComplete(Subtask<T> subtask) {
if (subtask.state() == Subtask.State.SUCCESS) {
results.add(subtask.get());
}
return false;
}
@Override
public List<T> timeout() {
throw new JoinerTimeoutException(
new StructuredTaskScope.CancelledByTimeoutException());
}
@Override
public List<T> result() {
return List.copyOf(results);
}
}
static Integer randomTask(int maxDuration, int threshold)
throws InterruptedException, TooSlowException {
int t = new Random().nextInt(maxDuration);
System.out.println("Duration: " + t);
if (t > threshold) {
throw new TooSlowException("Duration " + t + " greater than threshold " + threshold);
}
Thread.sleep(t);
return Integer.valueOf(t);
}
static <T> List<T> allSuccessful(List<Callable<T>> tasks)
throws InterruptedException {
try (var scope = StructuredTaskScope.open(
new CollectingJoiner<T>(),
cf -> cf.withTimeout(Duration.ofMillis(200)))) {
tasks.forEach(scope::fork);
return scope.join();
}
}
static void testCollectingJoiner() {
List<Callable<Integer>> subtasks =
IntStream.range(0, 10)
.mapToObj(i -> (Callable<Integer>) () -> randomTask(1000, 700))
.collect(Collectors.toList());
try {
allSuccessful(subtasks).forEach(r -> System.out.println("Result: " + r));
} catch (JoinerTimeoutException e) {
System.out.println("JoinerTimeoutException: " + e.getMessage());
System.out.println("Cause: " + e.getCause().getClass().getSimpleName());
} catch (InterruptedException e) {
System.out.println("InterruptedException");
}
}
public static void main(String[] args) {
testCollectingJoiner();
}
}It prints output similar to the following:
Duration: 610
Duration: 158
Duration: 322
Duration: 469
Duration: 354
Duration: 612
Duration: 995
Duration: 884
Duration: 238
Duration: 701
JoinerTimeoutException: Structured task scope timed out
Cause: CancelledByTimeoutExceptionConfiguring StructuredTaskScope
The methods StructuredTaskScope.open(UnaryOperator<StructuredTaskScope.Configuration>) and StructuredTaskScope.open(Joiner, UnaryOperator<StructuredTaskScope.Configuration>) accept a StructuredTaskScope.Configuration 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. If the timeout expires before or while waiting in the join method, then the scope is canceled and join throws an ExecutionException with CancelledByTimeoutException as the cause:
public class ConfiguredRandomTasksExample {
static class TooSlowException extends Exception {
TooSlowException(String s) {
super(s);
}
}
static Integer randomTask(int maxDuration, int threshold)
throws InterruptedException, TooSlowException {
int t = new Random().nextInt(maxDuration);
System.out.println("Duration: " + t);
if (t > threshold) {
throw new TooSlowException("Duration " + t + " greater than threshold " + threshold);
}
Thread.sleep(t);
return Integer.valueOf(t);
}
static 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);
var results = scope.join();
results.forEach(r -> System.out.println("Result: " + r));
} catch (InterruptedException e) {
System.out.println("InterruptedException");
} catch (ExecutionException e) {
Throwable cause = e.getCause();
System.out.println("ExecutionException: "
+ cause.getClass().getSimpleName() + ": " + cause.getMessage());
}
}
public static void main(String[] args) {
runConcurrentlyConfiguredRandomTasks();
}
}This example prints output similar to the following:
Duration: 56
Duration: 5
Duration: 797
Duration: 685
Duration: 828
ExecutionException: CancelledByTimeoutException: nullScope 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.Configuration 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, java.util.concurrent.ExecutionException {
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<String>allSuccessfulOrThrow(),
cf -> cf.withThreadFactory(factory)
.withName("RandomTaskScope"))) {
Subtask<String> task0 = scope.fork(() -> sleepOneSecond("task0"));
Subtask<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"))) {
Subtask<String> task2a = subtaskscope.fork(() -> sleepOneSecond("task2a"));
Subtask<String> task2b = subtaskscope.fork(() -> sleepOneSecond("task2b"));
results += String.join(", ", subtaskscope.join());
}
return results;
};
Subtask<String> task2 = scope.fork(t);
try (var childscope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<String>allSuccessfulOrThrow(),
cf -> cf.withThreadFactory(factory)
.withName("RamdomTaskSubscope"))) {
Subtask<String> task3a = childscope.fork(() -> sleepOneSecond("task3a"));
Subtask<String> task3b = childscope.fork(() -> sleepOneSecond("task3b"));
childscope.join().forEach(r -> System.out.println("Result in RamdomTaskSubscope: " + r));
}
scope.join().forEach(r -> System.out.println("Result in RandomTaskScope: " + r));
}
}
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: 30176, name: task2a, thread name: RandomTask-5
PID: 30176, name: task1, thread name: RandomTask-1
PID: 30176, name: task3a, thread name: RandomTask-3
PID: 30176, name: task3b, thread name: RandomTask-4
PID: 30176, name: task0, thread name: RandomTask-0
PID: 30176, name: task2b, thread name: RandomTask-6
PID: 30176, name: task1, thread name: RandomTask-1
PID: 30176, name: task2a, thread name: RandomTask-5
PID: 30176, name: task3b, thread name: RandomTask-4
PID: 30176, name: task3a, thread name: RandomTask-3
PID: 30176, name: task0, thread name: RandomTask-0
PID: 30176, name: task2b, thread name: RandomTask-6
...
Result in RamdomTaskSubscope: RandomTask-3
Result in RamdomTaskSubscope: RandomTask-4
Result in RandomTaskScope: RandomTask-0
Result in RandomTaskScope: RandomTask-1
Result in RandomTaskScope: Result in RandomTaskScopeInsideSubtask: RandomTask-5, 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": 30176,
"time": "2026-04-28T16:12:36.781736400Z",
"runtimeVersion": "27",
"threadContainers": [
{
"container": "<root>",
"parent": null,
"owner": null,
"threads": [
{
"tid": 3,
"time": "2026-04-28T16:12:37.014441800Z",
"name": "main",
"state": "WAITING",
"stack": [
"java.base\/jdk.internal.misc.Unsafe.park(Native Method)",
"java.base\/java.util.concurrent.locks.LockSupport.park(LockSupport.java:369)",
"java.base\/jdk.internal.misc.ThreadFlock.awaitAll(ThreadFlock.java:305)",
"java.base\/java.util.concurrent.StructuredTaskScopeImpl.join(StructuredTaskScopeImpl.java:240)",
"SCObservable.handle(SCObservable.java:51)",
"SCObservable.main(SCObservable.java:60)"
]
},
... other threads omitted ...
],
"threadCount": 8
},
... ForkJoinPool and ThreadPoolExecutor containers omitted
{
"container": "RandomTaskScope\/jdk.internal.misc.ThreadFlock$ThreadContainerImpl@62107e9f",
"parent": "<root>",
"owner": 3,
"threads": [
{
"tid": 34,
"time": "2026-04-28T16:12:37.070646700Z",
"virtual": true,
"name": "RandomTask-0",
"state": "TIMED_WAITING",
... Stack trace omitted ...
},
{
"tid": 36,
"time": "2026-04-28T16:12:37.070646700Z",
"virtual": true,
"name": "RandomTask-1",
"state": "TIMED_WAITING",
... Stack trace omitted ...
},
{
"tid": 38,
"time": "2026-04-28T16:12:37.071644800Z",
"virtual": true,
"name": "RandomTask-2",
"state": "WAITING",
... Stack trace omitted ...
}
],
"threadCount": 3
},
{
"container": "RamdomTaskSubscope\/jdk.internal.misc.ThreadFlock$ThreadContainerImpl@7f9554e7",
"parent": "RandomTaskScope\/jdk.internal.misc.ThreadFlock$ThreadContainerImpl@62107e9f",
"owner": 3,
"threads": [
{
"tid": 40,
"time": "2026-04-28T16:12:37.072644600Z",
"virtual": true,
"name": "RandomTask-3",
"state": "TIMED_WAITING",
... Stack trace omitted ...
},
{
"tid": 41,
"time": "2026-04-28T16:12:37.073647Z",
"virtual": true,
"name": "RandomTask-4",
"state": "TIMED_WAITING",
... Stack trace omitted ...
}
],
"threadCount": 2
},
{
"container": "RandomTaskScopeInsideSubtask\/jdk.internal.misc.ThreadFlock$ThreadContainerImpl@654f2b01",
"parent": "RandomTaskScope\/jdk.internal.misc.ThreadFlock$ThreadContainerImpl@62107e9f",
"owner": 38,
"threads": [
{
"tid": 44,
"time": "2026-04-28T16:12:37.073647Z",
"virtual": true,
"name": "RandomTask-5",
"state": "TIMED_WAITING",
... Stack trace omitted ...
},
{
"tid": 46,
"time": "2026-04-28T16:12:37.073647Z",
"virtual": true,
"name": "RandomTask-6",
"state": "TIMED_WAITING",
... Stack trace omitted ...
}
],
"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.