Structured Concurrency (JEP 505): Preview 5 — What Changed?
Note: JEP 505 is a preview feature in Java 25 (5th preview). Enable with
--enable-preview. The API has been stable for several rounds and is expected to finalize in Java 26.
The Problem with Unstructured Concurrency
Classic Java concurrency with ExecutorService is unstructured: you submit tasks, and those tasks have no formal relationship with the code that submitted them. This causes three recurring problems:
Problem 1: Partial failure leaves orphaned tasks
ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor();
Future<User> userFuture = exec.submit(() -> fetchUser(userId));
Future<Orders> ordersFuture = exec.submit(() -> fetchOrders(userId));
User user = userFuture.get(); // blocks here
Orders orders = ordersFuture.get(); // also blocks
If fetchUser throws, ordersFuture is never cancelled. It keeps running in the background, consuming resources for a request that already failed.
Problem 2: Cancellation doesn’t propagate
If the outer thread is interrupted (e.g., the HTTP request times out), userFuture and ordersFuture are not automatically cancelled. You need to track and cancel them manually.
Problem 3: Stack traces are useless
When a subtask throws, the stack trace shows the thread pool thread — not the logical call chain. Debugging concurrent failures is a nightmare.
Structured Concurrency: The Core Idea
Structured Concurrency treats concurrent subtasks like a block of code: they must complete before the enclosing block exits. This mirrors structured programming’s rule that control flow must enter and exit a block in a well-defined order.
The API: StructuredTaskScope
try (var scope = new StructuredTaskScope<T>()) {
var task1 = scope.fork(() -> ...);
var task2 = scope.fork(() -> ...);
scope.join(); // wait for all tasks
// use task1.get(), task2.get()
}
// scope.close() is called automatically — all tasks are done or cancelled
Guarantee: when you exit the try block (normally or via exception), all forked tasks have either completed or been cancelled. No orphans.
ShutdownOnFailure: Any Failure Cancels All
The most common pattern: run multiple tasks in parallel; if any one fails, cancel the rest immediately.
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.ShutdownOnFailure;
record UserProfile(User user, List<Order> orders, List<Product> recommendations) {}
public UserProfile loadUserDashboard(String userId) throws Exception {
try (var scope = new ShutdownOnFailure()) {
var userTask = scope.fork(() -> fetchUser(userId));
var ordersTask = scope.fork(() -> fetchOrders(userId));
var recoTask = scope.fork(() -> fetchRecommendations(userId));
scope.join() // wait for all three
.throwIfFailed(); // re-throw if any failed (cancels others automatically)
return new UserProfile(
userTask.get(),
ordersTask.get(),
recoTask.get()
);
}
}
If fetchOrders throws a DatabaseException, fetchUser and fetchRecommendations are cancelled immediately. The throwIfFailed() call re-throws the original exception. The caller gets a clean failure with a useful stack trace.
ShutdownOnSuccess: First Result Wins
Use this when you have multiple providers and want the first successful result:
import java.util.concurrent.StructuredTaskScope.ShutdownOnSuccess;
public String resolveConfig(String key) throws Exception {
try (var scope = new ShutdownOnSuccess<String>()) {
scope.fork(() -> fetchFromVault(key)); // HashiCorp Vault
scope.fork(() -> fetchFromConfigServer(key)); // Spring Cloud Config
scope.fork(() -> fetchFromEnv(key)); // environment variable
scope.join();
return scope.result(); // returns the first non-exceptional result
}
}
The first task to return a value causes all others to be cancelled. You don’t wait for the slowest provider.
Nested Scopes and Scope Inheritance
Structured concurrency scopes can be nested. Inner scopes must complete before outer scopes:
public ReportData buildReport(String reportId) throws Exception {
try (var outerScope = new ShutdownOnFailure()) {
var headerTask = outerScope.fork(() -> fetchHeader(reportId));
var bodyTask = outerScope.fork(() -> {
// Inner scope — its tasks are children of this fork
try (var innerScope = new ShutdownOnFailure()) {
var section1 = innerScope.fork(() -> fetchSection(reportId, 1));
var section2 = innerScope.fork(() -> fetchSection(reportId, 2));
var section3 = innerScope.fork(() -> fetchSection(reportId, 3));
innerScope.join().throwIfFailed();
return new Body(section1.get(), section2.get(), section3.get());
}
});
outerScope.join().throwIfFailed();
return new ReportData(headerTask.get(), bodyTask.get());
}
}
If fetchSection(reportId, 2) fails, section1 and section3 are cancelled, the inner scope re-throws, the outer bodyTask fails, headerTask is cancelled, and the outer scope re-throws. Clean failure propagation at every level.
Deadline / Timeout
Set a timeout on the entire scope:
public String fetchWithTimeout(String url) throws Exception {
try (var scope = new ShutdownOnFailure()) {
var task = scope.fork(() -> httpGet(url));
// Cancel all tasks if not done within 2 seconds
scope.joinUntil(Instant.now().plusSeconds(2));
scope.throwIfFailed();
return task.get();
}
}
If the 2-second deadline passes, all forked tasks are cancelled and joinUntil throws TimeoutException.
Real-World Example: Parallel API Aggregation
A common microservices pattern: aggregate data from three downstream services to build a single API response.
import module java.base;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.ShutdownOnFailure;
record ProductPage(Product product, List<Review> reviews, PriceInfo pricing) {}
public class ProductService {
public ProductPage getProductPage(String productId) throws Exception {
try (var scope = new ShutdownOnFailure()) {
var productTask = scope.fork(() ->
catalogClient.getProduct(productId)
);
var reviewsTask = scope.fork(() ->
reviewClient.getReviews(productId)
);
var pricingTask = scope.fork(() ->
pricingClient.getPricing(productId)
);
// Wait up to 500ms for all three
scope.joinUntil(Instant.now().plusMillis(500));
scope.throwIfFailed();
return new ProductPage(
productTask.get(),
reviewsTask.get(),
pricingTask.get()
);
}
}
}
All three calls run in parallel on virtual threads. Total latency = max(catalog, reviews, pricing) — not the sum. With 100ms per service, you get a 100ms response instead of 300ms.
What Changed in the Java 25 Preview?
The Java 25 (5th preview) version of StructuredTaskScope is largely API-stable compared to Java 21. The main evolution has been:
| Change | Details |
|---|---|
Joiner API (Java 24+) | Extensible join policy via StructuredTaskScope.Joiner<T,R> — lets you build custom aggregation strategies beyond ShutdownOnFailure / ShutdownOnSuccess |
scope.fork() return type | Now returns StructuredTaskScope.Subtask<T> instead of Future<T> — cleaner API, Subtask.get() doesn’t throw checked exceptions |
| ScopedValue integration | Forked tasks automatically inherit the parent’s ScopedValue bindings |
Custom Joiner: Collect All Results
With the Joiner API (Java 24+), you can aggregate all results — not just the first or throw on any:
public List<SearchResult> searchAll(String query) throws Exception {
try (var scope = StructuredTaskScope.open(Joiner.allSuccessfulOrThrow())) {
scope.fork(() -> searchDatabase(query));
scope.fork(() -> searchElasticsearch(query));
scope.fork(() -> searchCache(query));
return scope.join()
.results()
.toList(); // returns results from all successful tasks
}
}
Summary
Structured Concurrency solves the three classic problems of unstructured concurrency:
| Problem | Unstructured | Structured |
|---|---|---|
| Orphaned tasks on failure | Tasks keep running | Auto-cancelled |
| Cancellation propagation | Manual Future.cancel() | Automatic |
| Stack traces | Thread pool thread | Logical call chain |
| Timeout | Manual Future.get(timeout) per task | joinUntil(instant) on the scope |
It is still in preview in Java 25 — but after 5 rounds, the API is stable enough to use in non-public-API code today.
Next up: Compact Object Headers →