Introduction: From First Adoption to a More Mature Concurrency Stack
If you're already using Loom in production, moving from Java 21 to Java 25 feels like more than routine JDK churn. Java 21 gave us production-ready virtual threads and preview structured concurrency. Java 25 keeps virtual threads stable, refines structured concurrency, and promotes scoped values to a permanent feature.
If you've already shipped Loom-based services on Java 21, Java 25 mostly feels like the model settling down: fewer sharp edges, clearer APIs, and better defaults.
Java 21 Recap: The Baseline We Migrated From
Virtual Threads in Java 21
Java 21 (JEP 444) made virtual threads a permanent feature.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<User> user = executor.submit(() -> userService.fetchUser(userId));
Future<List<Order>> orders = executor.submit(() -> orderService.fetchOrders(userId));
return new UserDashboard(user.get(), orders.get());
}
This model remains valid in Java 25.
Structured Concurrency in Java 21 (Preview)
Java 21 (JEP 453) exposed StructuredTaskScope as a preview class with built-in subclasses such as ShutdownOnFailure and ShutdownOnSuccess.
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> userService.fetchUser(userId));
var orders = scope.fork(() -> orderService.fetchOrders(userId));
scope.join();
scope.throwIfFailed();
return new UserDashboard(user.get(), orders.get());
}
Scoped Values in Java 21 (Preview)
Scoped values were preview in Java 21 (JEP 446), and many Java 21 codebases used the runWhere / callWhere style:
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
ScopedValue.runWhere(REQUEST_ID, requestId, () -> handleRequest());
Java 25 Updates: Stabilized, Preview, and Removed APIs
Feature Status from Java 21 to Java 25
| Area | Java 21 | Java 25 | What Changed for Migrators |
|---|
| Virtual Threads | Final (JEP 444) | Final | API remains stable |
| Structured Concurrency | Preview (JEP 453) | Preview (JEP 505) | Major API redesign |
| Scoped Values | Preview (JEP 446) | Final (JEP 506) | No preview flag needed anymore |
| Virtual-thread pinning diagnostics flag | -Djdk.tracePinnedThreads available | Less central after JEP 491 (landed in 24) | Prefer JFR event-driven diagnostics, remove stale flag-by-default usage |
Structured Concurrency in Java 25: New API Shape
Java 25 keeps structured concurrency in preview but changes the API substantially:
StructuredTaskScope is now opened with static open(...) methods.
- Policy is defined by
Joiner instead of subclassing (ShutdownOnFailure, ShutdownOnSuccess).
join() behavior now follows the chosen joiner policy.
- For timeout handling, this codebase kept explicit deadline checks during migration.
Java 25 example replacing Java 21 ShutdownOnFailure:
import java.util.concurrent.StructuredTaskScope;
import static java.util.concurrent.StructuredTaskScope.Joiner;
try (var scope = StructuredTaskScope.open(Joiner.awaitAllSuccessfulOrThrow())) {
var user = scope.fork(() -> userService.fetchUser(userId));
var orders = scope.fork(() -> orderService.fetchOrders(userId));
scope.join();
return new UserDashboard(user.get(), orders.get());
}
First-success pattern in Java 25:
import static java.util.concurrent.StructuredTaskScope.Joiner;
import java.util.concurrent.StructuredTaskScope.Subtask;
import java.util.concurrent.Callable;
import java.util.stream.Stream;
public <T> T runFirstSuccess(Callable<T>... tasks) throws Exception {
try (var scope = StructuredTaskScope.open(
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"));
}
}
Timeout handling pattern used in this project:
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();
if (Instant.now().isAfter(deadline)) {
throw new TimeoutException("Operation exceeded timeout: " + timeout);
}
return future.get();
}
}
In fuller Java 25 previews, use cfg.withTimeout() for built-in timeout handling.
Scoped Values in Java 25: Finalized API
Scoped values are now permanent in Java 25 and use the fluent carrier style:
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
ScopedValue.where(REQUEST_ID, requestId).run(() -> handleRequest());
If your Java 21 code used runWhere / callWhere, migrate to where(...).run(...) / where(...).call(...).
Key Improvements vs Java 21
- Virtual thread API remains stable, so existing code keeps working.
- JEP 491 improvements (available since Java 24, included in Java 25) reduce pinning scenarios around
synchronized and monitor waits.
- Result: better scalability behavior for legacy synchronized code without requiring broad lock rewrites.
2. Error Handling and Cancellation Clarity
- In Java 21, forgetting
throwIfFailed() was a common bug source.
- In Java 25, the joiner outcome drives
join() behavior directly, so failure handling is harder to miss.
- Scope policy (all succeed, first success, custom policy) is explicit at scope creation.
3. Scoping and Context Propagation
- Scoped values are now stable, reducing upgrade risk for context propagation.
- Scoped-value inheritance remains efficient for subtasks in structured scopes.
- The API is cleaner and more consistent after fluent-only refinements.
jdk.VirtualThreadPinned JFR event remains relevant for remaining pinning cases.
jdk.tracePinnedThreads still exists, but JFR-based diagnostics are now the better default in modern JDKs.
Migration Insights: What Worked Well, and Gotchas We Hit
What Worked Well
- Virtual-thread-based executors needed little to no rewriting.
- Scoped-value adoption became easier to standardize after finalization in Java 25.
- Structured concurrency usage became more explicit and easier to review in code review.
Gotchas and Fixes
| Java 21 Pattern | Java 25 Replacement | Migration Note |
|---|
new StructuredTaskScope.ShutdownOnFailure() | StructuredTaskScope.open() or open(Joiner.awaitAllSuccessfulOrThrow()) | Constructor/subclass model replaced |
new StructuredTaskScope.ShutdownOnSuccess<T>() | StructuredTaskScope.open(Joiner.<T>allUntil(...)) | Use joiner policy, then select first success from joined subtasks |
scope.throwIfFailed() | handled by join() outcome | Remove explicit post-join failure check |
scope.joinUntil(deadline) | Explicit deadline checks in scope wrappers | Kept migration small while preserving timeout behavior |
scope.joinUntil(deadline) | open(..., cfg -> cfg.withTimeout(duration)) | Prefer this built-in approach once your preview API level supports it cleanly |
scope.shutdown() short-circuit logic | short-circuit via joiner policy | Cancellation policy lives in joiner |
ScopedValue.runWhere/callWhere | ScopedValue.where(...).run/call | Fluent API only |
Build Configuration Reminder
Structured concurrency is still preview in Java 25.
javac --release 25 --enable-preview ...
java --enable-preview ...
If you remove preview flags globally, structured-concurrency code will fail to compile/run.
Conclusion: Why Java 25 Is Worth the Upgrade
For Loom users, Java 25 is a practical upgrade from Java 21:
- Virtual threads remain stable and production-ready.
- Structured concurrency is still preview, but the API is cleaner and more composable.
- Scoped values are now finalized, reducing risk for context-propagation design.
- Runtime improvements from the 22-24 cycle (now part of 25) make real-world concurrency behavior safer and easier to operate.
The migration is mostly mechanical, with practical benefits: clearer concurrency code, fewer lifecycle mistakes, and a steadier long-term path for structured parallelism.
Source Code
Sources