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.
Why Structured Concurrency Exists
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:
- related tasks are launched in different places,
- cancellation is inconsistent,
- failure handling is fragmented,
- cleanup is easy to miss.
Structured concurrency addresses this by making related concurrent work live inside an explicit scope with one lifecycle owner.
Core Model
Structured concurrency in Java 21 preview is centered on StructuredTaskScope.
A parent scope:
- forks related subtasks,
- waits for completion,
- handles failure policy,
- guarantees cleanup when the scope closes.
That structure makes task boundaries visible in code and easier to maintain.
Where Traditional Approaches Get Messy
Manual Thread Management
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();
}
Ad-hoc Future Composition
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.
Java 21 Preview API: Basic Pattern
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:
- one scope owns all subtasks,
- fail-fast semantics are explicit,
- scope closure handles cleanup deterministically.
Common Java 21 Preview Limitations
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.
- keep side effects idempotent where possible.
Relationship with Virtual Threads
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:
- virtual threads keep blocking code straightforward,
- structured scopes keep lifecycle/error policy explicit.
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.
Practical Scenarios
Service Aggregation
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());
}
Parallel Repository Queries
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());
}
When to Use (and Not Use)
Good fit:
- I/O-bound fan-out/fan-in request paths,
- orchestrating multiple dependency calls with shared failure policy,
- flows that need reliable cancellation and cleanup behavior.
Lower value cases:
- purely CPU-bound batch computation,
- long-lived background workers not tied to request scope,
- systems where preview-feature usage is not allowed.
Build Setup (Java 21 Preview)
<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 ...
Preview Limitations and Upgrade Path
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.
Resources