Series navigation
Written by
Jagdish Salgotra
Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.
Was this useful?
Most of the migration was mechanical. ShutdownOnFailure became a Joiner, throwIfFailed disappeared, and StructuredTaskScope.open replaced the constructor. Two things were not mechanical, and those are the ones worth reading.
Written by
Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.
Was this useful?
The migration started with a compile error. ShutdownOnFailure was gone. Not deprecated, not replaced with a warning, just gone. That is where the Java 25 structured concurrency story begins: the subclass model is out, and a Joiner passed into StructuredTaskScope.open() is in.
Once I understood that one substitution, the rest followed. And throwIfFailed() disappearing was a relief, not a problem. It was always the call you had to remember to make after join(). Java 25 moves that responsibility into the joiner itself, where it belongs.
This is a migration appendix for teams on Java 21 preview who want to understand what actually changes. The core idea stays identical. The API surface gets cleaner.
| Feature | Java 21 | Java 25 | Impact |
|---|---|---|---|
| Structured Concurrency | Preview (JEP 453) | Preview (JEP 505) | API redesigned |
| Scoped Values | Preview (JEP 446) | Final (JEP 506) | Stable, pairs cleanly with scopes |
| Virtual Threads | Final (JEP 444) | Final | No change |
The execution model is stable. Virtual threads, lifecycle guarantees, cancellation propagation, none of that moves. What changes is how you express the policy for what happens when a subtask fails or succeeds.
Composition over inheritance, same lifecycle guarantees, fewer missed failure checks.
Every extends StructuredTaskScope.ShutdownOnFailure becomes a Joiner passed into StructuredTaskScope.open(). That is the migration. Everything else follows from it.
Java 21:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var fetch1 = scope.fork(() -> fetchFromService1());
var fetch2 = scope.fork(() -> fetchFromService2());
scope.join();
scope.throwIfFailed();
return fetch1.get() + fetch2.get();
}
Java 25:
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow())) {
var fetch1 = scope.fork(() -> fetchFromService1());
var fetch2 = scope.fork(() -> fetchFromService2());
scope.join();
return fetch1.get() + fetch2.get();
}
throwIfFailed() is gone. The joiner handles it. scope.result() is gone too. You call .get() directly on the subtask.
public <T> T runInScopeWithTimeout(Callable<T> task, Duration timeout) throws Exception {
Instant deadline = Instant.now().plus(timeout);
try (var scope = StructuredTaskScope.open(StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow())) {
var future = scope.fork(task);
scope.join();
// Explicit deadline check for compatibility; adopt cfg.withTimeout() in fuller previews.
if (Instant.now().isAfter(deadline)) {
throw new TimeoutException("Operation exceeded timeout: " + timeout);
}
return future.get();
}
}
Java 21 required a custom subclass. Java 25 uses a joiner:
public <T> T runFirstSuccess(Callable<T>... tasks) throws Exception {
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<T>allUntil(
s -> s.state() == Subtask.State.SUCCESS))) {
for (Callable<T> task : tasks) {
scope.fork(task);
}
Stream<Subtask<T>> results = scope.join();
return results
.filter(s -> s.state() == Subtask.State.SUCCESS)
.findFirst()
.map(Subtask::get)
.orElseThrow(() -> new Exception("No successful result"));
}
}
joinUntil(Instant) is gone. The pattern that worked in this project is an explicit deadline check in a wrapper method:
public <T> T runWithTimeout(Callable<T> task, Duration timeout) throws Exception {
Instant deadline = Instant.now().plus(timeout);
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow())) {
var future = scope.fork(task);
scope.join();
if (Instant.now().isAfter(deadline)) {
throw new TimeoutException("Exceeded timeout: " + timeout);
}
return future.get();
}
}
Scoped values are final in Java 25. The fluent API replaces the old static style:
Java 21:
ScopedValue.runWhere(USER_ID, userId, () -> handleBusinessLogic());
Java 25:
ScopedValue.where(USER_ID, userId)
.where(REQUEST_ID, requestId)
.run(() -> handleBusinessLogic());
Cleaner to read, especially when binding multiple values before entering a scope.
Compile errors, not runtime surprises. In order:
ShutdownOnFailure and ShutdownOnSuccess no longer exist as subclassesthrowIfFailed() does not compilejoinUntil(Instant) does not compileScopedValue.runWhere and callWhere must move to fluent styleJoiner strategiesBusiness logic inside subtasks did not need touching. Virtual thread behavior did not change. The breakage was entirely at the scope construction and join call sites.
StructuredTaskScope.open() and choose a joinerthrowIfFailed() and scope.result()joinUntil(Instant) with explicit deadline checks in wrapper methodsJoiner strategies--enable-preview on both compile and runtime pathsStructured concurrency is still preview in Java 25. JEP 505 is the current spec. Follow it for finalization timeline signals. The API has moved meaningfully between Java 21 and Java 25 and could move again before it stabilizes.
The API is not final yet. But the direction is clear enough to migrate with confidence.