Java Structured Concurrency (Java 21+)
Structured concurrency (JEP 453, finalized in Java 21 as a preview: stabilized in later releases) treats a group of concurrent subtasks as a single unit of work. When the parent scope closes, all subtasks are either done or cancelled, eliminating the thread-leak and partial-failure problems that plague unstructured CompletableFuture chains.
Contents
The Problem with Unstructured Concurrency
With CompletableFutureExecutorService
// Unstructured — what happens if fetchUser fails?
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> fetchUser(id));
CompletableFuture<Order> orderFuture = CompletableFuture.supplyAsync(() -> fetchOrder(id));
// If fetchUser throws, fetchOrder keeps running — wasting resources
// If we cancel one, the other is not automatically cancelled
// Error handling requires careful chaining — easy to miss exceptions
User user = userFuture.get();
Order order = orderFuture.get();
Structured concurrency solves this: the scope owns all subtasks. When the scope closes (by either success, failure, or explicit shutdown), all outstanding subtasks are cancelled automatically.
ShutdownOnFailure: All Must Succeed
ShutdownOnFailurejoin()
record UserProfile(User user, List<Order> orders, List<Address> addresses) {}
UserProfile fetchProfile(long userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Fork subtasks — each runs on a virtual thread
StructuredTaskScope.Subtask<User> userTask = scope.fork(() -> fetchUser(userId));
StructuredTaskScope.Subtask<List<Order>> ordersTask = scope.fork(() -> fetchOrders(userId));
StructuredTaskScope.Subtask<List<Address>> addressTask = scope.fork(() -> fetchAddresses(userId));
// Wait for all subtasks — or for any to fail
scope.join()
.throwIfFailed(); // re-throws the first failure as an exception
// All succeeded — safe to call result()
return new UserProfile(
userTask.get(),
ordersTask.get(),
addressTask.get()
);
}
// On scope close: any still-running subtasks are cancelled automatically
}
fetchOrdersfetchUserfetchAddressesthrowIfFailed()ShutdownOnSuccess: First to Succeed Wins
ShutdownOnSuccess
String fetchFromFastest(String key) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
// Race primary vs replica database
scope.fork(() -> primaryDb.get(key));
scope.fork(() -> replicaDb.get(key));
scope.fork(() -> cache.get(key)); // also try cache
scope.join(); // waits until first succeeds (or all fail)
return scope.result(); // returns the first successful result
}
}
// Another example — try multiple geocoding services
Coordinate geocode(String address) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<Coordinate>()) {
scope.fork(() -> googleMaps.geocode(address));
scope.fork(() -> openStreetMap.geocode(address));
scope.fork(() -> hereApi.geocode(address));
scope.join();
return scope.result();
}
}
Error Propagation and Cancellation
StructuredTaskScope.ShutdownOnFailurescope.throwIfFailed()join()ShutdownOnSuccessCompletableFuture
// Subtask states after scope.join():
// UNAVAILABLE — not started
// SUCCESS — completed successfully (call .get() to retrieve result)
// FAILED — threw an exception (call .exception() to get it)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var task1 = scope.fork(() -> step1());
var task2 = scope.fork(() -> step2());
var task3 = scope.fork(() -> step3());
scope.join().throwIfFailed(e -> new RuntimeException("Pipeline failed", e));
// Inspect individual results
System.out.println("Step1: " + task1.get());
System.out.println("Step2: " + task2.get());
System.out.println("Step3: " + task3.get());
}
// Manual cancellation — scope.shutdown() can be called early
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var task = scope.fork(() -> longRunningOp());
// Timeout: cancel after 2 seconds
scope.joinUntil(Instant.now().plusSeconds(2));
if (task.state() != StructuredTaskScope.Subtask.State.SUCCESS) {
throw new TimeoutException("Operation timed out");
}
return task.get();
}
--enable-previewComparison with CompletableFuture
- Lifecycle: StructuredTaskScope scope is bounded. All tasks complete or are cancelled by the time the try-with-resources block exits. CompletableFuture tasks can outlive the code that created them.
- Cancellation: Structured concurrency cancels remaining subtasks automatically. With CompletableFuture you must manually cancel each.
- Error handling:
is simple and predictable. CompletableFuture error chaining (throwIfFailed() ,exceptionally ) is complex.handle - Observability: Because structured tasks are scoped, debuggers and profilers can show the complete subtask tree.
- Use CompletableFuture when: you need complex async pipelines across method boundaries, or you cannot use preview features.
- Use StructuredTaskScope when: you fork-and-join within a single method and want clean lifecycle management.
// Clean pattern: parallel fetch with structured concurrency
record PageData(String content, List<String> links, Map<String, String> metadata) {}
PageData scrapePage(URL url) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var content = scope.fork(() -> fetchContent(url));
var links = scope.fork(() -> extractLinks(url));
var metadata = scope.fork(() -> fetchMetadata(url));
scope.join().throwIfFailed();
return new PageData(content.get(), links.get(), metadata.get());
}
}