Structured Concurrency (Preview)
See how StructuredTaskScope, a preview API in Java 25, ties parallel subtasks to one block with shared failure and cancellation.
try (var scope = StructuredTaskScope.open()) {
Subtask<A> a = scope.fork(() -> loadA());
Subtask<B> b = scope.fork(() -> loadB());
scope.join();
return combine(a.get(), b.get());
}With an ExecutorService, subtasks are not tied to the code that started them. If one of two parallel calls fails, the other keeps running; if the request thread is interrupted, the subtasks do not notice. Structured concurrency makes concurrent code follow the shape of the block it is written in: subtasks start inside a scope, and the scope does not close until all of them have finished or been cancelled.
In Java 25 this is the fifth preview (JEP 505), with an API reshaped from earlier previews. Preview APIs can change between releases and need --enable-preview both to compile and to run, so treat what follows as a look ahead rather than something to ship in production code today.
The Java 25 shape
try (var scope = StructuredTaskScope.open()) {
Subtask<User> user = scope.fork(() -> findUser(id));
Subtask<List<Order>> orders = scope.fork(() -> findOrders(id));
scope.join(); // wait for both, throw if either failed
return new Profile(user.get(), orders.get());
}open() creates a scope whose policy is "all must succeed". Each fork starts the subtask in a new virtual thread by default. join() waits; if any subtask fails, the others are cancelled and join() throws StructuredTaskScope.FailedException with the original exception as its cause. Leaving the try block closes the scope, so no thread outlives it.
Joiners choose the policy
Joiner.awaitAllSuccessfulOrThrow(): the default used byopen().Joiner.allSuccessfulOrThrow():join()returns a stream of the successful subtasks.Joiner.anySuccessfulResultOrThrow(): returns the first successful result and cancels the rest; useful for racing replicas.Joiner.awaitAll(): waits for everything and lets you inspect each subtask's state.
A second open overload takes a function that adjusts the configuration, such as cf -> cf.withTimeout(Duration.ofSeconds(2)). When the deadline passes, the remaining subtasks are cancelled and join() throws StructuredTaskScope.TimeoutException.
Why it matters for services
- A failed downstream call cancels its siblings, so you stop paying for work whose result will be thrown away.
- JSON thread dumps show subtasks grouped under their parent scope, which makes fan-out code easier to debug.
- Forked subtasks inherit
ScopedValuebindings, so the current user or request id is visible in parallel work without copying.
Rules to remember: only the thread that opened the scope may call join(), it must be called before reading results, and Subtask.get() throws if that subtask did not succeed. Until the API is final, the runnable alternative is an ExecutorService or CompletableFuture with explicit cancellation.
Example
// Preview in Java 25: needs --enable-preview
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
record Profile(User user, List<Order> orders) {}
Profile loadProfile(String userId) throws InterruptedException {
try (var scope = StructuredTaskScope.open()) {
Subtask<User> user = scope.fork(() -> userService.find(userId));
Subtask<List<Order>> orders = scope.fork(() -> orderService.recent(userId));
scope.join(); // waits for both; if one fails, the other is cancelled
// and join() throws StructuredTaskScope.FailedException
return new Profile(user.get(), orders.get());
} // closing the scope guarantees no subtask is still running
}When to use it
- Load a user profile and its orders in parallel and fail the whole request quickly if either call fails.
- Query the same data from several regional replicas and return whichever answers first.
- Put a single two-second deadline on a fan-out to many pricing calls for one product page.
More examples
Racing replicas with anySuccessfulResultOrThrow
join() returns the first successful result and the scope cancels the slower calls. It throws only if every subtask fails.
// Preview in Java 25: needs --enable-preview
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Joiner;
String fastestQuote(List<String> regions) throws InterruptedException {
try (var scope = StructuredTaskScope.open(Joiner.<String>anySuccessfulResultOrThrow())) {
for (String region : regions) {
scope.fork(() -> quoteService.quote(region));
}
return scope.join(); // the first success wins; the rest are cancelled
}
}A deadline for the whole fan-out
allSuccessfulOrThrow makes join() return a stream of the finished subtasks. One timeout covers every subtask, instead of a separate timeout per call.
// Preview in Java 25: needs --enable-preview
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Joiner;
import java.util.concurrent.StructuredTaskScope.Subtask;
List<Price> prices(List<String> skus) throws InterruptedException {
try (var scope = StructuredTaskScope.open(
Joiner.<Price>allSuccessfulOrThrow(),
cf -> cf.withTimeout(Duration.ofSeconds(2)))) {
for (String sku : skus) {
scope.fork(() -> pricingService.price(sku));
}
return scope.join().map(Subtask::get).toList();
} catch (StructuredTaskScope.TimeoutException e) {
throw new IllegalStateException("pricing took longer than 2 s", e);
}
}What the preview fixes: a sibling keeps running today
With a plain executor, the failed call does not affect its sibling and you must cancel it yourself. A StructuredTaskScope does that cancellation automatically.
void main() throws InterruptedException {
CountDownLatch ordersReply = new CountDownLatch(1); // the orders service never answers
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> user = executor.submit(() -> {
throw new IllegalStateException("user service down");
});
Future<String> orders = executor.submit(() -> {
ordersReply.await();
return "orders";
});
try {
user.get();
} catch (ExecutionException e) {
IO.println("user failed: " + e.getCause().getMessage());
IO.println("orders still running: " + !orders.isDone());
orders.cancel(true); // with an executor, cancelling siblings is your job
IO.println("orders cancelled: " + orders.isCancelled());
}
}
}
// Output:
// user failed: user service down
// orders still running: true
// orders cancelled: true
Discussion