

Structured Concurrency was developed in Project Loom, along with Virtual Threads and Scoped Values. Structured Concurrency has been included in the JDK as an incubator feature since since Java 19 and as a preview feature since Java 21.
In this article, you will learn:
- Why do we need Structured Concurrency?
- What is Structured Concurrency?
- How is
StructuredTaskScope
used? - What is a policy? What policies are there, and how can we write our own policy?
- What is the advantage of Structured Concurrency?
You can find a companion demo application (with Java 21 and Java 25 code) in this GitHub repository.
Let’s start by looking at how we have implemented concurrent subtasks so far.
Why Do We Need Structured Concurrency?
Suppose a task consists of various – mainly blocking – subtasks that can be done concurrently (e.g., accessing data from a database or calling a remote API).
We could implement this using the Java executable framework, which could look like this, for example (InvoiceGenerator3_ThreadPool class in the demo application):
Invoice createInvoice(int orderId, int customerId, String language)
throws InterruptedException, ExecutionException {
Future<Order> orderFuture =
executor.submit(() -> orderService.getOrder(orderId));
Future<Customer> customerFuture =
executor.submit(() -> customerService.getCustomer(customerId));
Future<InvoiceTemplate> invoiceTemplateFuture =
executor.submit(() -> invoiceTemplateService.getTemplate(language));
Order order = orderFuture.get();
Customer customer = customerFuture.get();
InvoiceTemplate invoiceTemplate = invoiceTemplateFuture.get();
return Invoice.generate(order, customer, invoiceTemplate);
}
Code language: Java (java)
We pass the three subtasks to the Executor
and wait for the partial results. The happy path is quickly implemented. But how do we handle exceptions?
- If an error occurs in one subtask – how can we cancel the others? In the example above, if
loadOrderFromOrderService(…)
fails, thenorderFuture.get()
throws an exception, thecreateInvoice(…)
method ends, and we may have two orphan threads still running. - How can we abort the subtasks when the parent task (“create invoice”) is cancelled – or when the complete application is shut down?
- How can we – in an alternative use case – cancel the remaining subtasks when only the result of a single subtask is needed?
Everything is doable but requires exceptionally complex, hard-to-maintain code (you can find two examples in the GitHub repository: InvoiceGenerator2b_CompletableFutureCancelling and InvoiceGenerator4b_NewVirtualThreadPerTaskCancelling).
And what if we want to debug code of this kind? A thread dump, for example, would give us a bunch of threads named “pool-X-thread-Y” – but we wouldn’t know which pool thread belongs to which calling threads since all calling threads share the executor’s thread pool.
What is Unstructured Concurrency?
“Unstructured concurrency” means that our tasks run in a web of tangled threads whose start and end is hard to see in the code. Clean error handling is usually not present, and orphaned threads often occur when a control structure (in the example above: the createInvoice(…)
method) ends:

What is Structured Concurrency?
Introduced in Java 19 as an incubator feature and in Java 21 as a preview feature – and revised again in Java 25 – Structured Concurrency is a concept that significantly improves the implementation, readability, and maintainability of code for dividing a task into subtasks and processing them concurrently.
For this purpose, it introduces a new control structure – the StructuredTaskScope
class – that
- defines a clear scope at the beginning of which the subtasks' threads start and at the end of which the subtasks' threads end,
- that allows clean error handling,
- and that allows a clean cancellation of subtasks whose results are no longer needed.
I will show you in the following sections, using several examples, what this means exactly.
StructuredTaskScope Example
We can implement Structured Concurrency using the StructuredTaskScope
class. Using this class, we can rewrite the example as follows.
StructuredTaskScope Example – Java 21–24
Class InvoiceGenerator5_StructuredTaskScope in the java-21
branch of the demo application:
Invoice createInvoice(int orderId, int customerId, String language)
throws InterruptedException {
try (var scope = new StructuredTaskScope<>()) {
Subtask<Order> orderSubtask =
scope.fork(() -> orderService.getOrder(orderId));
Subtask<Customer> customerSubtask =
scope.fork(() -> customerService.getCustomer(customerId));
Subtask<InvoiceTemplate> invoiceTemplateSubtask =
scope.fork(() -> invoiceTemplateService.getTemplate(language));
scope.join();
Order order = orderSubtask.get();
Customer customer = customerSubtask.get();
InvoiceTemplate template = invoiceTemplateSubtask.get();
return Invoice.generate(order, customer, template);
}
}
Code language: Java (java)
A common explanation for all Java versions follows below the Java 25 example.
StructuredTaskScope Example – Java 25
Class InvoiceGenerator5_StructuredTaskScope in the main
branch of the demo application:
Invoice createInvoice(int orderId, int customerId, String language)
throws InterruptedException {
try (var scope = StructuredTaskScope.open()) {
Subtask<Order> orderSubtask =
scope.fork(() -> orderService.getOrder(orderId));
Subtask<Customer> customerSubtask =
scope.fork(() -> customerService.getCustomer(customerId));
Subtask<InvoiceTemplate> invoiceTemplateSubtask =
scope.fork(() -> invoiceTemplateService.getTemplate(language));
scope.join();
Order order = orderSubtask.get();
Customer customer = customerSubtask.get();
InvoiceTemplate template = invoiceTemplateSubtask.get();
return Invoice.generate(order, customer, template);
}
}
Code language: Java (java)
In this simple example, the only difference is how we open a StructuredTaskScope
: before Java 25 with new StructuredTaskScope<>()
and from Java 25 onwards with StructuredTaskScope.open()
.
Explanations for all Java Versions
Compared to the unstructured concurrency example, we replace the ExecutorService
in the scope of the class with a StructuredTaskScope
located in the method’s scope – and executor.submit()
with scope.fork()
.
Using scope.join()
, we wait for all tasks to be completed. This eliminates the risk of orphaned threads.
After that, we can read the results of the three tasks via Subtask.get()
.
StructuredTaskScope Error Handling – Java 21–24
If an exception occurred in one of the tasks, Subtask.get()
throws an IllegalStateException
. Therefore it is better to query the state of a subtask with state()
before calling get()
:
Order order;
if (orderSubtask.state() == Subtask.State.SUCCESS) {
order = orderSubtask.get();
} else {
// Handle error
}
Code language: Java (java)
StructuredTaskScope Error Handling – Java 25
If an exception occurs in one of the subtasks in Java 25, that exception will be thrown by scope.join()
– wrapped in a StructuredTaskScope.FailedException
. It is therefore not necessary to check the subtask status after calling scope.join()
.
Running the Example Code
If you want to try the example yourself: you must explicitly enable preview features, with --enable-preview --source <used Java version>
. You can find detailed instructions in the README of the demo application.
StructuredTaskScope Policies
A so-called policy defines what happens when a subtask is completed or throws an exception. A policy can also define a return value for scope.join()
– more on this later.
Policies in Java 21–24
Before Java 25, a scope opened by new StructuredTaskScope()
had the policy of waiting for all subtasks to be completed successfully or with an exception.
However, if an exception occurs in one of the tasks in our example, we cannot do anything with the results of the other two tasks - so why wait for them?
Java 21–24: “Shutdown on Failure” Policy
Using the “Shutdown on Failure” policy in Java 21–24, we can specify that the occurrence of an exception in one task will cause all other tasks to be terminated.
We can use the “Shutdown on Failure” policy as follows (you can find the code in the InvoiceGenerator6_ShutdownOnFailure class in the java-21
branch of the GitHub repo):
Invoice createInvoice(int orderId, int customerId, String language)
throws InterruptedException, ExecutionException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<Order> orderSubtask =
scope.fork(() -> orderService.getOrder(orderId));
Subtask<Customer> customerSubtask =
scope.fork(() -> customerService.getCustomer(customerId));
Subtask<InvoiceTemplate> invoiceTemplateSubtask =
scope.fork(() -> invoiceTemplateService.getTemplate(language));
scope.join();
scope.throwIfFailed();
Order order = orderSubtask.get();
Customer customer = customerSubtask.get();
InvoiceTemplate template = invoiceTemplateSubtask.get();
return Invoice.generate(order, customer, template);
}
}
Code language: Java (java)
Compared to the previous example, I had to change only two things:
- I replaced
new StructuredTaskScope<>()
withnew StructuredTaskScope.ShutdownOnFailure()
in the third line. - I added the command
scope.throwIfFailed()
afterscope.join()
.
Now, if an exception occurs in any of the three tasks, all other subtasks are immediately interrupted, scope.join()
returns, and scope.throwIfFailed()
throws the failed subtask’s exception embedded in an ExecutionException
.
In the sample code, the three subtasks throw an exception with some probability. If you run the program a few times, you will see how an exception in one task leads to an interruption in the other tasks and a termination of the program:
$ java -cp target/classes --enable-preview eu.happycoders.structuredconcurrency/demo1_invoice/InvoiceGenerator6_ShutdownOnFailure
[Thread[#1,main,5,main]] Forking tasks
[Thread[#1,main,5,main]] Waiting for all tasks to finish or one to fail
[VirtualThread[#31]/runnable@ForkJoinPool-1-worker-2] Loading customer
[VirtualThread[#29]/runnable@ForkJoinPool-1-worker-3] Loading order
[VirtualThread[#35]/runnable@ForkJoinPool-1-worker-1] Loading template
[VirtualThread[#31]/runnable@ForkJoinPool-1-worker-1] Finished loading customer
[VirtualThread[#29]/runnable@ForkJoinPool-1-worker-2] Error loading order
[VirtualThread[#35]/runnable@ForkJoinPool-1-worker-1] Template loading was interrupted
Exception in thread "main" java.util.concurrent.ExecutionException: java.lang.RuntimeException: Error loading order
[...]
Code language: plaintext (plaintext)
By the way, you can also see from this output that all tasks are executed in virtual threads.
Policies in Java 25
A scope opened in Java 25 with StructuredTaskScope.open()
has the policy of immediately canceling all other subtasks when an exception occurs in one of the subtasks and throwing a FailedException
via scope.join()
with the exception that occurred in the subtask as the “cause”.
So “Shutdown on Failure” by default.
A call to scope.throwIfFailed()
as in Java 21–24 is not necessary – the throwIfFailed()
method no longer exists in Java 25.
In Java 25, the program output when an error occurs looks like this, for example:
[Thread[#3,main,5,main]] Forking tasks
[VirtualThread[#37]/runnable@ForkJoinPool-1-worker-1] Loading order
[VirtualThread[#39]/runnable@ForkJoinPool-1-worker-4] Loading customer
[Thread[#3,main,5,main]] Waiting for all tasks to finish
[VirtualThread[#44]/runnable@ForkJoinPool-1-worker-3] Loading template
[VirtualThread[#39]/runnable@ForkJoinPool-1-worker-3] Error loading customer
[VirtualThread[#37]/runnable@ForkJoinPool-1-worker-1] Order loading was interrupted
[VirtualThread[#44]/runnable@ForkJoinPool-1-worker-1] Template loading was interrupted
Exception in thread "main" java.util.concurrent.StructuredTaskScope$FailedException: java.lang.RuntimeException: Error loading customer
Code language: plaintext (plaintext)
As you can see, instead of a generic ExecutionException
as in Java 21-24, we get a StructuredTaskScope
-specific FailedException
from Java 25 onwards.
Java 21–24: “Shutdown on Success” Policy
An alternative policy is “Shutdown on Success”. With this policy, the scope is shut down as soon as a subtask is successful. The other subtasks are then canceled.
In Java 21–24, you create a scope with the “Shutdown on Success” policy using new StructuredTaskScope.ShutdownOnSuccess()
. You can read the result of the one successful subtask with scope.result()
.
Here’s an example of this – with a different use case: We want to verify a customer address using multiple external APIs at the same time, and we only want to use the first result (you can find the code in the AddressVerification2_ShutdownOnSuccess class in the java-21
branch of the GitHub repo):
AddressVerificationResponse verifyAddress(Address address)
throws InterruptedException, ExecutionException {
try (var scope = new ShutdownOnSuccess<AddressVerificationResponse>()) {
scope.fork(() -> verificationService.verifyViaServiceA(address));
scope.fork(() -> verificationService.verifyViaServiceB(address));
scope.fork(() -> verificationService.verifyViaServiceC(address));
scope.join();
return scope.result();
}
}
Code language: Java (java)
Here scope.join()
waits for the first subtask to be successfully completed – then scope.result()
returns its result. If, contrary to expectations, all three invocations of the verifyViaServiceX()
method threw an exception, scope.result()
will rethrow the first of them, embedded in an ExecutionException
.
If you run the sample code, you will see how the first successful subtask produces a result, and the other tasks get aborted:
$ java -cp target/classes --enable-preview eu.happycoders.structuredconcurrency/demo2_address/AddressVerification2_ShutdownOnSuccess
[Thread[#1,main,5,main]] Forking tasks
[Thread[#1,main,5,main]] Waiting for one task to finish
[VirtualThread[#31]/runnable@ForkJoinPool-1-worker-2] Verifying address via service B
[VirtualThread[#29]/runnable@ForkJoinPool-1-worker-3] Verifying address via service A
[VirtualThread[#34]/runnable@ForkJoinPool-1-worker-1] Verifying address via service C
[VirtualThread[#34]/runnable@ForkJoinPool-1-worker-1] Finished loading address via service C
[Thread[#1,main,5,main]] Retrieving result
[VirtualThread[#31]/runnable@ForkJoinPool-1-worker-3] Verifying address via service B was interrupted
[VirtualThread[#29]/runnable@ForkJoinPool-1-worker-2] Verifying address via service A was interrupted
Code language: plaintext (plaintext)
Note that the result()
method is only available on ShutdownOnSuccess
, and the throwIfFailed()
method is only available on ShutdownOnFailure
.
Java 25: “Any Successful Result”-Policy
In Java 25, the corresponding policy is called Any Successful Result”. With this policy, the scope is also closed as soon as the first subtask was successful. The other subtasks are terminated.
In Java 25, a policy is no longer implemented by extending the StructuredTaskScope
class, as was the case in Java 21–24. From Java 25 onwards, policies are implemented using so-called joiners.
A joiner for the “Any Successful Result” policy is created via the static factory method Joiner.anySuccessfulResultOrThrow()
.
The following code shows how the address verification example is implemented with Java 25 (AddressVerification2_AnySuccessfulResult class in the main
branch of the GitHub repo):
AddressVerificationResponse verifyAddress(Address address) throws InterruptedException {
try (var scope =
StructuredTaskScope.open(
Joiner.<AddressVerificationResponse>anySuccessfulResultOrThrow())) {
log("Forking tasks");
scope.fork(() -> verificationService.verifyViaServiceA(address));
scope.fork(() -> verificationService.verifyViaServiceB(address));
scope.fork(() -> verificationService.verifyViaServiceC(address));
log("Waiting for one task to finish");
return scope.join();
}
}
Code language: Java (java)
Changes compared to Java 21–24:
- The scope is opened with
StructuredTaskScope.open(Joiner.anySuccessfulResultOrThrow()))
– no longer withnew StructuredTaskScope.ShutdownOnSuccess()
. - The
scope.result()
method was removed – instead,scope.join()
returns the result.
If all three subtasks fail,scope.join()
throws aFailedException
instead of anExecutionException
.
Java 25: All Policies
In Java 21–24 there are only the “Shutdown on Failure” and “Shutdown on Success” policies shown above.
There are significantly more policies to choose from in Java 25:
Joiner | Description |
---|---|
without a joiner or Joiner.awaitAllSuccessfulOrThrow() | An exception in a subtask immediately leads to the scope being closed; scope.join() throws the exception wrapped in a FailedException .If all subtasks are successfully completed, scope.join() ends without an exception and returns null . The results of the subtasks must be read from the Subtask objects returned by scope.fork() .Corresponds to the “Shutdown on Failure” policy in Java 21–24. |
Joiner.awaitAll() | scope.join() waits for all subtasks to be completed – whether successful or not.scope.join() always returns null ; the results of the subtasks must be read from the Subtask objects returned by scope.fork() .Corresponds to the standard behavior in Java 21–24. |
Joiner .anySuccessfulResultOrThrow() | scope.join() returns the result of the first successful subtask; other subtasks are canceled. If all subtasks fail, scope.join() throws the exception of the first failed subtask wrapped in a FailedException .Corresponds to the “Shutdown on Success” policy in Java 21–24. |
Joiner .allSuccessfulOrThrow() | Corresponds to the basic functionality without joiner or with Joiner.awaitAllSuccessfulOrThrow() – with the difference that scope.join() returns a stream of all subtasks if successful.An exception in a subtask immediately leads to the scope being closed; scope.join() throws the exception wrapped in a FailedException .If all subtasks are successfully completed, scope.join() ends without an exception and returns a stream of all subtasks. |
Joiner.allUntil(Predicate isDone) | scope.join() waits for either all subtasks to be finished – whether successful or not – or for the passed predicate to match at least one finished subtask.Returns a stream of subtasks like Joiner.allSuccessfulOrThrow() . |
How to Write a Custom StructuredTaskScope Policy
If none of the predefined policies are suitable for your application, you can write your own policy with relatively little effort.
Suppose we want to check the availability of a product from multiple suppliers, and we don't want to use the first result, but the one with the fastest availability. We want to propagate failed requests only if the requests failed for all suppliers.
That is surprisingly simple to realize – and in a way that makes it reusable for other deployment scenarios.
Custom Policies in Java 21–24
Here is the policy for Java 21–24 (BestResultScope class in the java-21
branch of the GitHub repo). This class takes a Comparator
as a constructor parameter, which we will use later to define the best result as the fastest availability:
BestResultScope
also extends the StructuredTaskScope
class, overwrites its handleComplete(…)
method and adds a resultOrElseThrow()
method:
public class BestResultScope<T> extends StructuredTaskScope<T> {
private final Comparator<T> comparator;
private T bestResult;
private final List<Throwable> exceptions =
Collections.synchronizedList(new ArrayList<>());
public BestResultScope(Comparator<T> comparator) {
this.comparator = comparator;
}
@Override
protected void handleComplete(Subtask<? extends T> subtask) {
switch (subtask.state()) {
case UNAVAILABLE -> {
// Ignore
}
case SUCCESS -> {
T result = subtask.get();
synchronized (this) {
if (bestResult == null || comparator.compare(result, bestResult) > 0) {
bestResult = result;
}
}
}
case FAILED -> exceptions.add(subtask.exception());
}
}
public <X extends Throwable> T resultOrElseThrow(
Supplier<? extends X> exceptionSupplier) throws X {
ensureOwnerAndJoined();
if (bestResult != null) {
return bestResult;
} else {
X exception = exceptionSupplier.get();
exceptions.forEach(exception::addSuppressed);
throw exception;
}
}
}
Code language: Java (java)
The handleComplete(…)
method is called for each terminated subtask – both successful ones and those that threw an exception. We check which case has occurred with subtask.state()
.
If successful, we fetch the result with subtask.get()
and write it – if it is better than the best result so far – in a thread-safe way into the bestResult
field.
In case of an exception, we collect them in a thread-safe list.
The resultOrElseThrow()
method first ensures by calling ensureOwnerAndJoined()
that it has been called from the same thread that created the StructuredTaskScope
and that this thread has previously called join()
or joinUntil(…)
.
resultOrElseThrow()
then checks if a successful result is available and, if yes, returns it. Otherwise it throws the specified exception to which it appends the collected exceptions as “suppressed exceptions.”
We can use the custom policy as follows (SupplierDeliveryTimeCheck2_StructuredTaskScope class in java-21
branch of the GitHub repo):
SupplierDeliveryTime getSupplierDeliveryTime(String productId, List<String> supplierIds)
throws SupplierDeliveryTimeCheckException, InterruptedException {
try (var scope =
new BestResultScope<>(
Comparator.comparing(SupplierDeliveryTime::deliveryTimeHours).reversed())) {
for (String supplierId : supplierIds) {
scope.fork(() -> service.getDeliveryTime(productId, supplierId));
}
scope.join();
return scope.resultOrElseThrow(SupplierDeliveryTimeCheckException::new);
}
}
Code language: Java (java)
The output of the sample program could look like this, for example:
$ java -cp target/classes --enable-preview eu.happycoders.structuredconcurrency/demo3_suppliers/SupplierDeliveryTimeCheck2_StructuredTaskScope
[VirtualThread[#31]/runnable@ForkJoinPool-1-worker-2] Retrieving delivery time from supplier B
[VirtualThread[#33]/runnable@ForkJoinPool-1-worker-4] Retrieving delivery time from supplier D
[VirtualThread[#34]/runnable@ForkJoinPool-1-worker-3] Retrieving delivery time from supplier E
[VirtualThread[#32]/runnable@ForkJoinPool-1-worker-5] Retrieving delivery time from supplier C
[VirtualThread[#29]/runnable@ForkJoinPool-1-worker-3] Retrieving delivery time from supplier A
[VirtualThread[#31]/runnable@ForkJoinPool-1-worker-3] Error retrieving delivery time from supplier B
[VirtualThread[#29]/runnable@ForkJoinPool-1-worker-5] Finished retrieving delivery time from supplier A: 110 hours
[VirtualThread[#32]/runnable@ForkJoinPool-1-worker-3] Finished retrieving delivery time from supplier C: 104 hours
[VirtualThread[#34]/runnable@ForkJoinPool-1-worker-3] Error retrieving delivery time from supplier E
[VirtualThread[#33]/runnable@ForkJoinPool-1-worker-3] Finished retrieving delivery time from supplier D: 51 hours
[Thread[#1,main,5,main]] Response: SupplierDeliveryTime[supplier=D, deliveryTimeHours=51]
Code language: plaintext (plaintext)
You can see how although the call for suppliers B and E failed, the remaining suppliers delivered results and in the end, the best result – supplier D with 51 hours delivery time – is returned.
Custom Policy in Java 25
In Java 25, we defined a custom policy not by extending StructuredTaskScope
, but by implementing the Joiner
interface.
Here is the corresponding code for the joiner (BestResultJoiner class in the main branch of the GitHub repo):
public class BestResultJoiner<T> implements Joiner<T, T> {
private final Comparator<T> comparator;
private T bestResult;
private final List<Throwable> exceptions =
Collections.synchronizedList(new ArrayList<>());
public BestResultJoiner(Comparator<T> comparator) {
this.comparator = comparator;
}
@Override
public boolean onComplete(Subtask<? extends T> subtask) {
switch (subtask.state()) {
case UNAVAILABLE -> {
// Ignore
}
case SUCCESS -> {
T result = subtask.get();
synchronized (this) {
if (bestResult == null || comparator.compare(result, bestResult) > 0) {
bestResult = result;
}
}
}
case FAILED -> exceptions.add(subtask.exception());
}
return false; // Don't cancel the scope
}
@Override
public T result() throws SupplierDeliveryTimeCheckException {
if (bestResult != null) {
return bestResult;
} else {
SupplierDeliveryTimeCheckException exception =
new SupplierDeliveryTimeCheckException();
exceptions.forEach(exception::addSuppressed);
throw exception;
}
}
}
Code language: Java (java)
The onComplete()
method is largely the same as the previous handleComplete()
method. The only difference: onComplete()
returns a boolean
with which the method can specify whether the scope should be canceled (true
stands for cancel).
The result()
method largely corresponds to the previous resultOrElseThrow()
method, but the result()
method is not passed an exception supplier and must decide for itself which exception it throws. If you want to make the exception type variable, as in the example for Java 21–24, you could pass the corresponding supplier to the joiner’s constructor.
Nested StructuredTaskScopes
If we want to query not only the suppliers for one product at a time, but the suppliers for multiple products, we can easily solve this as follows:
Here is the code for Java 21–24 (class SupplierDeliveryTimeCheck3_NestedStructuredTaskScope in the java-21
branch of the GitHub repo):
List<SupplierDeliveryTime> getSupplierDeliveryTimes(
List<String> productIds, List<String> supplierIds) throws InterruptedException {
try (var scope = new StructuredTaskScope<SupplierDeliveryTime>()) {
List<Subtask<SupplierDeliveryTime>> subtasks =
productIds.stream()
.map(productId ->
scope.fork(() -> getSupplierDeliveryTime(productId, supplierIds)))
.toList();
scope.join();
return subtasks.stream()
.filter(subtask -> subtask.state() == State.SUCCESS)
.map(Subtask::get)
.toList();
}
}
Code language: Java (java)
And here is the corresponding code for Java 25 (class SupplierDeliveryTimeCheck3_NestedStructuredTaskScope in the main
branch of the GitHub repo). This time I use Joiner.allSuccessfulOrThrow()
to get a stream of the subtasks directly from scope.join()
– so I don't have to remember the subtasks beforehand.
List<SupplierDeliveryTime> getSupplierDeliveryTimes(
List<String> productIds, List<String> supplierIds) throws InterruptedException {
try (var scope =
StructuredTaskScope.open(Joiner.<SupplierDeliveryTime>allSuccessfulOrThrow())) {
productIds.forEach(
productId -> scope.fork(() -> getSupplierDeliveryTime(productId, supplierIds)));
return scope.join().map(Subtask::get).toList();
}
}
Code language: Java (java)
In both examples, we create a StructuredTaskScope
– and within this scope, we fork subtasks, which in turn call the getSupplierDeliveryTime(…)
method shown in the previous section, which thus open nested scopes within the scope of getSupplierDeliveryTimes(…)
.
The following image shows these scopes as dashed lines:

Advantages of Structured Concurrency
Structured Concurrency is characterized by start and end points of concurrent subtasks clearly visible in the code. Errors in the subtasks are propagated to the parent scope. This makes the code easier to read and maintain and ensures that all started threads are finished at the end of a scope.
The following figure shows a comparison of unstructured and structured concurrency:

Advantages of StructuredTaskScope
With StructuredTaskScope
we have a Java language construct for structured concurrency:
- Task and subtasks form a self-contained unit in the code – there is no
ExecutorService
in a higher scope. The threads do not come from a thread pool; instead, each subtask is executed in a new virtual thread. - The scope spanned by the try-with-resources block results in clear start and end points of all threads.
- At the end of the scope, all threads are finished.
- Errors within the subtasks are propagated cleanly to the parent scope.
- Depending on the policy, the remaining subtasks are aborted if a subtask was successful or if an error occurred in a subtask.
- When the calling thread is canceled, the subtasks are also canceled.
- The call hierarchy between the calling thread and the subtask-executing threads is visible in the thread dump.
In addition, StructuredTaskScope
helps with debugging: If we create a thread dump in the new JSON format (jcmd <pid> Thread.dump_to_file -format=json <file>
), then it will reflect the call hierarchy between parent and child threads.
StructuredTaskScope and Scoped Values
Scoped Values, introduced in Java 20 as an incubator feature, in Java 21 as a preview feature, and finalized in Java 25, are automatically inherited by all child threads created by StructuredTaskScope.fork(…)
when StructuredTaskScope
is used within the scope of a Scoped Value.
I’ll show you exactly how this works with the following code example (SupplierDeliveryTimeCheck4_NestedStructuredTaskScopeUsingScopedValue in the java-21
branch; SupplierDeliveryTimeCheck4_NestedStructuredTaskScopeUsingScopedValue in the main
branch).
We create a ScopedValue
– in the example, for an API key, bind it to the API key, and then call the getSupplierDeliveryTimes(…)
method shown in the section “Nested StructuredTaskScopes” within the scope via call(
:…
)
public static final ScopedValue<String> API_KEY = ScopedValue.newInstance();
List<SupplierDeliveryTime> getSupplierDeliveryTimes(
List<String> productIds, List<String> supplierIds, String apiKey) throws Exception {
return ScopedValue.where(API_KEY, apiKey)
.call(() -> getSupplierDeliveryTimes(productIds, supplierIds));
}
Code language: Java (java)
Due to the inheritance of the scoped value API_KEY
, it can also be accessed within the SupplierDeliveryTimeService.getDeliveryTime(…)
method without having to pass it through method arguments to this method – even if the methods are not executed in the thread that calls ScopedValue.where(…)
but in the child or, in this example, even grandchild threads created by StructuredTaskScope.fork(…)
.
Summary
Structured Concurrency – building on virtual threads – will significantly simplify the management of tasks split into concurrent subtasks. Policies allow us to influence the behavior of StructuredTaskScope
, e.g., to abort all tasks should one of them fail.
The API has been fundamentally revised in Java 25: StructuredTaskScope
and join strategy have been decoupled, resulting in more clearly structured, more comprehensible and more robust code (composition over inheritance).
Please note that Structured Concurrency is still in the preview stage and may therefore still be subject to changes.