Structured Concurrency and Scoped Values in Java
The work on JEP-505 resulted in the introduction of structured concurrency in Java 25. In this article, updated in September 2025, we will delve into the concept of structured concurrency, compare it with the current API, and explore the problems it solves.
Join us on a journey through decades of Java evolution
What is structured concurrency
Structured concurrency means that all subtasks are bound to the scope of their parent task and cannot outlive it, just like a method call cannot last longer than the method that invoked it.
It makes it much easier to reason about concurrent code: no stray background threads continue running after the parent has finished, error handling is more predictable since failures can be managed in one place, and the overall system is simpler to observe and debug.
Check:
In practice, it prevents the typical pitfalls of uncontrolled concurrency, like thread leaks or tasks lingering indefinitely, often leading to fragile and hard-to-maintain systems.
In Java, structured concurrency can be used with virtual threads from Project Loom. Because virtual threads are extremely lightweight, creating new tasks is almost free, making this approach both practical and efficient even in highly concurrent applications.
Example: invoice generation with structured concurrency
To illustrate the issues with the current API, let’s consider a simple example: generating an invoice. We must fetch the issuer, the customer, and the list of items. Doing this sequentially on a single thread works, but it’s slow. A better approach would be to fetch the data in parallel.
Let’s first see how this looks with ExecutorService.
(Un)structured concurrency
Invoice generateInvoice() throws ExecutionException, InterruptedException {
try (final var executor = Executors.newFixedThreadPool(3)) {
Future<Issuer> issuer = executor.submit(this::findIssuer);
Future<Customer> customer = executor.submit(this::findCustomer);
Future<List<Item>> items = executor.submit(this::findItems);
return new Invoice(issuer.get(), customer.get(), items.get());
}
}
This approach brings with it a couple of problems:
- When
findCustomer
takes longer to execute thanfindItems
, andfindItems
throws an exception,
code will be blocked atcustomer.get()
, until the thread executingfindCustomer
ends. As a result, we will waste resources waiting for the subtask to finish. - If the thread calling
generateInvoice()
is interrupted, the subtasks in the executor are not interrupted and get leaked.
They can even operate indefinitely without control.
Structured concurrency
Invoice generateInvoice() throws InterruptedException {
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.allSuccessfulOrThrow() // (1)
)) {
Subtask<Issuer> issuer = scope.fork(this::findIssuer);
Subtask<Customer> customer = scope.fork(this::findCustomer);
Subtask<List<Item>> items = scope.fork(this::findItems);
scope.join(); // (2)
return new Invoice(issuer.get(), customer.get(), items.get());
}
}
- (1): We create a new
StructuredTaskScope
using the factory methodopen()
with the policyallSuccessfulOrThrow()
.
If an exception is thrown byfindIssuer
,findCustomer
, orfindItems
, the other subtasks will be canceled and the scope fails. - (2): Waits for all subtasks in scope to be finished and throws an exception on any failure.
The code above is free of the problems mentioned earlier:
- When
findCustomer
takes longer to execute thanfindItems
, butfindItems
throws an exception, the longer task will be canceled. - If the thread calling
generateInvoice()
is interrupted, cancellation is propagated to subtasks. - All subtasks will be completed before leaving the scope.
At first glance, not much has changed: instead of ExecutorService
we use StructuredTaskScope
, both implement AutoCloseable
, so we can use try-with-resources. But the difference is important: ExecutorService
usually uses platform threads, while StructuredTaskScope
uses lightweight virtual threads from the Loom project. Also, ExecutorService
returns Future<T>
, while here we work with Subtask<T>
.
Structured concurrency policies
A policy is a set of rules for how a StructuredTaskScope
should behave when its subtasks succeed or fail.
In the new API, these are implemented as Joiners.
allSuccessfulOrThrow
If any subtask fails, the rest is canceled and the scope fails. It works well in our invoice example - all data must be present.
anySuccessfulResultOrThrow
Stops the remaining subtasks once one of them succeeds or throws an exception if all fail. It is useful when calling multiple sources for the same data, and only the fastest one matters. Used for a typical race condition case.
allUntil
Runs all subtasks until a condition is met, then cancels the rest.
Other policies
If needed, you can implement your own policy by writing a custom StructuredTaskScope.Joiner<T, R>.
Scoped values
Scoped values are a mechanism to share immutable data between methods and child threads in a simple and safe way. They are easier to reason about than ThreadLocal, and have lower cost, mainly when used together with virtual threads and structured concurrency.
The scoped values API is finalized in JDK 25 (JEP 506). One small change compared to previews: the orElse method no longer accepts null.
Example: request ID in logging
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Joiner;
import static java.lang.ScopedValue.where;
public class LoggerExample {
private static final ScopedValue<String> REQUEST_ID =
ScopedValue.newInstance();
static void main() throws InterruptedException {
try (var scope = StructuredTaskScope
.open(Joiner.allSuccessfulOrThrow())) {
scope.fork(() -> where(REQUEST_ID, "abc-123")
.run(Main::handleRequest));
scope.fork(() -> where(REQUEST_ID, "xyz-789")
.run(Main::handleRequest));
scope.join();
}
}
static void handleRequest() {
log("handleRequest: start");
try (var scope = StructuredTaskScope
.open(Joiner.allSuccessfulOrThrow())) {
scope.fork(Main::queryDatabase);
scope.fork(Main::callExternalService);
scope.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log("handleRequest: done");
}
static void queryDatabase() {
log("Querying database...");
}
static void callExternalService() {
log("Calling external service...");
}
static void log(String message) {
String rid = REQUEST_ID.isBound() ?
REQUEST_ID.get() : "<unbound>";
System.out.println("[" + rid + "] " + message);
}
}
- We declare a scoped value REQUEST_ID to hold the request identifier.
- In main(), we fork two tasks, each binding REQUEST_ID to a different value (abc-123, xyz-789).
- In handleRequest, we create a structured scope and run subtasks in parallel; each subtask automatically sees the correct bound request ID.
- The log method reads REQUEST_ID dynamically, so every message is tagged with the proper request ID without passing it as an argument.
Example output:
[xyz-789] handleRequest: start
[abc-123] handleRequest: start
[abc-123] Querying database...
[xyz-789] Querying database...
[xyz-789] Calling external service...
[xyz-789] handleRequest: done
[abc-123] Calling external service...
[abc-123] handleRequest: done
This shows how scoped values let us pass context cleanly and efficiently through the call chain and into subtasks.
Summary
Structured concurrency and scoped values are a great addition to the Java standard API that simplify writing concurrent code.
- Structured concurrency (JEP 505) lets us manage groups of subtasks as a single unit of work, with predictable cancellation and error handling.
- Scoped values (JEP 506) provide an efficient and safe way to share immutable context data between methods and threads, without the problems of
ThreadLocal
.
Reviewed by: Sebastian Rabiej