Was this article helpful?
to mark as helpful
Enjoyed this article?
Get more engineering insights delivered weekly.
Comments
to join the discussion
to mark as helpful
Get more engineering insights delivered weekly.
to join the discussion
Jagdish Salgotra
Mar 22, 2026 · 15 min read · Structured Concurrency
Learn Java 21 structured concurrency with StructuredTaskScope preview APIs. This introduction covers scope-based task orchestration, predictable cancellation, error propagation, and preview setup requirements.
Your article assistant
Ask me anything about this article. I'll provide answers with relevant sources.
Try asking:
Note This series uses the Java 21 preview API for structured concurrency (
StructuredTaskScope, JEP 453). The API evolved in later previews. See Part 9 for Java 21 -> Java 25 migration guidance. Compile and run with--enable-preview.
Java has long supported concurrent programming through threads, executors, and futures. These tools are powerful, but request-level orchestration often becomes hard to reason about:
Structured concurrency addresses this by making related concurrent work live inside an explicit scope with one lifecycle owner.
Structured concurrency in Java 21 preview is centered on StructuredTaskScope.
A parent scope:
That structure makes task boundaries visible in code and easier to maintain.
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 50; i++) {
final int taskId = i;
Thread thread = Thread.startVirtualThread(() -> {
try {
monitor.trackTask("basic-task-" + taskId, () -> {
Thread.sleep(100 + ThreadLocalRandom.current().nextInt(200));
return "Task " + taskId + " completed";
});
} catch (Exception e) {
logger.info("Error occurred in task {}: {}", taskId, e.getMessage());
}
});
threads.add(thread);
}
for (Thread thread : threads) {
thread.join();
}
CompletableFuture<String> cf1 = CompletableFuture.supplyAsync(() -> simulateService("Service-A", 200));
CompletableFuture<String> cf2 = CompletableFuture.supplyAsync(() -> simulateService("Service-B", 300));
CompletableFuture<String> cf3 = CompletableFuture.supplyAsync(() -> simulateService("Service-C", 100));
CompletableFuture.allOf(cf1, cf2, cf3).join();
String result = String.format("Results: %s, %s, %s",
cf1.get(), cf2.get(), cf3.get());
This can work, but failure and cancellation policy usually spreads across multiple call sites.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<String> fetch1 = scope.fork(() -> fetchFromService1());
Subtask<String> fetch2 = scope.fork(() -> fetchFromService2());
Subtask<String> fetch3 = scope.fork(() -> fetchFromService3());
scope.join();
scope.throwIfFailed();
String result = fetch1.get() + fetch2.get() + fetch3.get();
logger.info("Combined result: {}", result);
}
Why this pattern is useful:
In project code, the safe baseline always pairs join() with throwIfFailed():
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var future = scope.fork(task);
scope.join();
scope.throwIfFailed();
return future.get();
}
Use a consistent review checklist for scope blocks:
fork all related tasks inside one scope.join before reading results.throwIfFailed before get.Structured concurrency is especially useful when tasks run on virtual threads (Project Loom, JEP 444). Virtual threads are final in Java 21, so they are safe to combine with this preview scoped API.
For I/O-bound fan-out work:
This is a strong combination for service orchestration. It is not a claim that all workloads become faster; CPU-bound behavior still depends on core count and scheduling limits.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var authService = scope.fork(() -> simulateServiceCall("auth-service", 100));
var userService = scope.fork(() -> simulateServiceCall("user-service", 150));
var notificationService = scope.fork(() -> simulateServiceCall("notification-service", 80));
var analyticsService = scope.fork(() -> simulateServiceCall("analytics-service", 200));
scope.join();
scope.throwIfFailed();
String result = String.format("Service Aggregation: %s, %s, %s, %s",
authService.get(), userService.get(), notificationService.get(), analyticsService.get());
}
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var userQuery = scope.fork(() -> simulateDbQuery("users", 150));
var orderQuery = scope.fork(() -> simulateDbQuery("orders", 120));
var productQuery = scope.fork(() -> simulateDbQuery("products", 180));
scope.join();
scope.throwIfFailed();
String result = String.format("DB Results: %s, %s, %s",
userQuery.get(), orderQuery.get(), productQuery.get());
}
Good fit:
Lower value cases:
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.13</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.13</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>21</source>
<target>21</target>
<compilerArgs>--enable-preview</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
Also run with preview enabled:
java --enable-preview ...
Structured concurrency was preview in Java 21 and remains preview in Java 25, with significant API evolution. Treat Java 21 examples as version-specific and test thoroughly before production rollout.
Part 9 in this series covers migration changes from Java 21 preview APIs to Java 25 preview APIs.