Skip to content

Java 26 Structured Concurrency: What's New in the Sixth Preview

The Problem with Traditional Concurrency

“I have a method that needs to call three different services. I parallelized them with CompletableFuture, but when one fails, the others keep running and I have no idea how to cancel them properly.”

This is a conversation I had with a developer last week. They had written concurrent code the “traditional” way—spawning threads, managing executors, chaining futures—and ended up with a mess of error handling and resource leaks.

Traditional Java concurrency has a fundamental problem: it treats concurrent subtasks as independent entities when they’re actually part of a single logical operation.

Consider this common pattern:

Traditional Concurrent Code
ExecutorService executor = Executors.newCachedThreadPool();
Future<String> userFuture = executor.submit(() -> fetchUser(userId));
Future<List<Order>> ordersFuture = executor.submit(() -> fetchOrders(userId));
try {
String user = userFuture.get();
List<Order> orders = ordersFuture.get();
return new UserProfile(user, orders);
} catch (InterruptedException | ExecutionException e) {
// What happens to the other future if one fails?
// How do I cancel both properly?
// What if fetchUser throws but fetchOrders is still running?
throw new RuntimeException(e);
}

The code looks reasonable. But here’s what goes wrong:

Traditional Concurrency Problems
+----------------------------------+
| fetchUser() fetchOrders() |
| | | |
| v v |
| SUCCESS FAILS |
| | | |
| | Exception! |
| | | |
| Still running! <-- Problem: |
| | | |
| Resource leak Orphaned task |
+----------------------------------+

When fetchOrders() fails, fetchUser() keeps running. If fetchUser() fails, ordersFuture is orphaned. Canceling futures properly requires careful cleanup code that most developers skip.

Direct Answer

Structured Concurrency treats multiple concurrent subtasks as a single unit of work. All subtasks complete together, fail together, or get canceled together.

Java 26’s JEP 525 is the sixth preview of this API. It works with Virtual Threads (finalized in Java 21) to make concurrent code that’s easy to write, read, and debug.

Structured Concurrency (Preview)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> user = scope.fork(() -> fetchUser(userId));
Future<List<Order>> orders = scope.fork(() -> fetchOrders(userId));
scope.join(); // Wait for both tasks
scope.throwIfFailed(); // Handle errors
return new UserProfile(user.result(), orders.result());
}

The difference is stark. When fetchOrders() fails in the structured version:

  1. The scope automatically shuts down
  2. fetchUser() gets canceled
  3. Resources are cleaned up
  4. The exception propagates cleanly

No manual cleanup. No orphaned threads. No resource leaks.

Why This Matters Now

Virtual Threads changed the game. Before Java 21, creating a million threads was impractical. Now it’s trivial:

Virtual Threads Scale
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// This is fine now - virtual threads are cheap
IntStream.range(0, 1_000_000).forEach(i -> {
executor.submit(() -> processRequest(i));
});
}

But with millions of threads, you need structure. You can’t manage thread lifecycles manually at that scale. Structured Concurrency provides the programming model that Virtual Threads require.

Together, they enable:

  • High-throughput applications without reactive programming complexity
  • Simple, readable code that looks synchronous
  • Reliable error handling and cancellation
  • Clear debugging with preserved call hierarchies

How Structured Concurrency Works

The core concept is the StructuredTaskScope. It creates a bounded context where concurrent subtasks execute:

Structured Concurrency Lifecycle
Main Thread
├── Create StructuredTaskScope
├── fork() ──────────────────┐
│ ├── Subtask 1 (Virtual Thread)
│ └── Subtask 2 (Virtual Thread)
├── join() ── Wait for all subtasks
├── throwIfFailed() ── Handle errors
├── Process results
└── Close scope (automatic via try-with-resources)

The scope guarantees:

  1. Lifetime binding: Subtasks cannot outlive their scope
  2. Cancellation propagation: Canceling the scope cancels all subtasks
  3. Error containment: One failure can trigger controlled shutdown
  4. Resource cleanup: try-with-resources ensures proper cleanup

The Three Scope Policies

Java provides three built-in policies for handling subtask failures:

ShutdownOnFailure

Cancel all running subtasks if any subtask fails:

ShutdownOnFailure Example
public UserProfile fetchProfile(Long userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> user = scope.fork(() -> fetchUser(userId));
Future<List<Order>> orders = scope.fork(() -> fetchOrders(userId));
Future<List<Payment>> payments = scope.fork(() -> fetchPayments(userId));
scope.join();
scope.throwIfFailed();
return new UserProfile(
user.result(),
orders.result(),
payments.result()
);
}
}

If fetchOrders() throws, fetchUser() and fetchPayments() get canceled immediately. You either get all results or an exception.

ShutdownOnSuccess

Complete as soon as any subtask succeeds:

ShutdownOnSuccess Example
public String fetchFromAnyMirror(Long fileId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
for (String mirror : MIRRORS) {
scope.fork(() -> fetchFromMirror(mirror, fileId));
}
scope.join();
return scope.result();
}
}

This is useful for redundant services. The first successful response wins; others get canceled.

Custom Policy

For complex scenarios, extend StructuredTaskScope:

Custom Policy Example
class BoundedScope<T> extends StructuredTaskScope<T> {
private final int maxConcurrency;
private int active = 0;
@Override
protected void handleComplete(Future<T> future) {
// Custom logic for handling completion
synchronized (this) {
active--;
notifyAll();
}
}
public void forkWithLimit(Callable<T> task) throws InterruptedException {
synchronized (this) {
while (active >= maxConcurrency) {
wait();
}
active++;
}
super.fork(task);
}
}

What Changed in the Sixth Preview

JEP 525 refines the API based on extensive community feedback. Key changes include:

  1. API Stabilization: The core API has stabilized after five preview iterations
  2. Improved Error Messages: Better diagnostics when scopes are misused
  3. Performance Optimizations: Better integration with the virtual thread scheduler
  4. Documentation Clarity: Clearer guidance on when to use each policy

The API hasn’t changed dramatically since earlier previews—the team is focused on polish and edge cases rather than major redesigns.

Practical Example: Building a Dashboard

Let me show you a real-world use case. I need to build a dashboard that aggregates data from multiple services:

Dashboard Aggregator
public record Dashboard(
UserInfo user,
List<Notification> notifications,
AccountSummary account,
List<Recommendation> recommendations
) {}
public Dashboard loadDashboard(Long userId) throws DashboardException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// All these calls run concurrently on virtual threads
Future<UserInfo> user = scope.fork(() -> userService.getUser(userId));
Future<List<Notification>> notifications = scope.fork(
() -> notificationService.getRecent(userId)
);
Future<AccountSummary> account = scope.fork(
() -> accountService.getSummary(userId)
);
Future<List<Recommendation>> recommendations = scope.fork(
() -> recommendationService.getForUser(userId)
);
try {
scope.join();
scope.throwIfFailed();
} catch (Exception e) {
// Wrap in domain exception with context
throw new DashboardException("Failed to load dashboard", e);
}
return new Dashboard(
user.result(),
notifications.result(),
account.result(),
recommendations.result()
);
}
}

Before Structured Concurrency, I would have needed:

  • A custom executor configuration
  • Manual future composition
  • Careful error handling with multiple catch blocks
  • Explicit resource cleanup
  • Thread pool management

Now it’s one try-with-resources block. The code is clear about its intent: run these tasks concurrently, fail if any fail, clean up automatically.

Debugging Structured Concurrency

One benefit that surprised me: debugging is much clearer.

With traditional concurrency, stack traces show thread dumps that don’t match your code structure:

Traditional Thread Dump
"pool-1-thread-3" #23 waiting on java.util.concurrent.ComtableFuture@7a811...
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:266)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(...)
... 20+ framework frames ...
at com.example.UserService.fetchUser(UserService.java:45)

With Structured Concurrency, the call hierarchy is preserved:

Structured Concurrency Stack Trace
Exception in thread "main" java.util.concurrent.ExecutionException: ...
at java.base/java.util.concurrent.StructuredTaskScope.throwIfFailed(...)
at com.example.DashboardService.loadDashboard(DashboardService.java:28)
at com.example.DashboardController.getDashboard(DashboardController.java:15)
... application frames ...
Caused by: java.lang.RuntimeException: User service unavailable
at com.example.UserService.fetchUser(UserService.java:47)

You can trace the exception from the dashboard controller through the scope to the actual failing service. No jumping between thread dumps.

When to Use Structured Concurrency

Use it when:

  • You have multiple independent operations that can run concurrently
  • All operations contribute to a single result
  • You want fail-fast behavior (cancel everything if one fails)
  • You’re already using Virtual Threads

Don’t use it for:

  • Fire-and-forget operations (use a regular executor)
  • Unrelated background tasks
  • Operations where partial results are acceptable
  • Legacy code that can’t migrate to Java 21+

Migration Path

If you’re on Java 21+ with existing concurrent code:

Before: CompletableFuture
public CompletableFuture<UserProfile> fetchProfile(Long userId) {
CompletableFuture<String> userFuture = CompletableFuture.supplyAsync(
() -> fetchUser(userId), executor
);
CompletableFuture<List<Order>> ordersFuture = CompletableFuture.supplyAsync(
() -> fetchOrders(userId), executor
);
return userFuture.thenCombine(ordersFuture, UserProfile::new);
}
After: Structured Concurrency
public UserProfile fetchProfile(Long userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> user = scope.fork(() -> fetchUser(userId));
Future<List<Order>> orders = scope.fork(() -> fetchOrders(userId));
scope.join();
scope.throwIfFailed();
return new UserProfile(user.result(), orders.result());
}
}

The tradeoff: synchronous-looking code instead of callback chains, but you need to handle checked exceptions.

Common Pitfalls

Pitfall #1: Blocking After fork()

Wrong: Blocking After Fork
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> result = scope.fork(() -> fetchUser(userId));
// WRONG: Blocking outside join()
String user = result.get(); // Don't do this!
scope.join();
}

Always use join() first, then access results.

Pitfall #2: Using Traditional Threads Inside Scopes

Wrong: Platform Threads in Scope
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// WRONG: Platform threads are expensive
scope.fork(() -> {
return Executors.newCachedThreadPool().submit(this::heavyWork).get();
});
}

Let Virtual Threads handle the concurrency. Don’t nest thread pools.

Pitfall #3: Ignoring Scope Closure

Wrong: Not Closing Scope
var scope = new StructuredTaskScope.ShutdownOnFailure();
scope.fork(() -> task1());
scope.fork(() -> task2());
scope.join();
// WRONG: Scope never closed, resources leaked

Always use try-with-resources.

Summary

Java 26’s JEP 525 brings Structured Concurrency closer to finalization. The key insight is treating concurrent subtasks as a cohesive unit rather than independent threads.

The combination of Virtual Threads and Structured Concurrency represents a fundamental shift in Java concurrency:

AspectTraditionalStructured
Thread modelExpensive platform threadsCheap virtual threads
Task managementManual executorsAutomatic scope lifecycle
Error handlingComplex try-catch chainsAutomatic propagation
CancellationManual, error-proneBuilt-in, reliable
DebuggingDisconnected stack tracesPreserved call hierarchy

If you’re building concurrent applications on Java 21+, start experimenting with Structured Concurrency now. The API is stable enough for production use with the preview flag, and the paradigm shift is worth the learning curve.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments