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
Aug 31, 2025 · 16 min read · Project Loom
Learn what changed for Project Loom when moving from Java 21 to Java 25. This guide covers stable virtual threads, redesigned structured concurrency APIs, finalized scoped values, migration patterns, and operational implications.
Your article assistant
Ask me anything about this article. I'll provide answers with relevant sources.
Try asking:
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 (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.
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 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());
| 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 |
Java 25 keeps structured concurrency in preview but changes the API substantially:
StructuredTaskScope is now opened with static open(...) methods.Joiner instead of subclassing (ShutdownOnFailure, ShutdownOnSuccess).join() behavior now follows the chosen joiner policy.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));
// throws StructuredTaskScope.FailedException if either subtask fails
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 {
// Custom joiner for first-success; standard Joiner.anySuccessfulResultOrThrow() in later previews.
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 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(...).
synchronized and monitor waits.throwIfFailed() was a common bug source.join() behavior directly, so failure handling is harder to miss.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.| 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 |
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.
For Loom users, Java 25 is a practical upgrade from Java 21:
The migration is mostly mechanical, with practical benefits: clearer concurrency code, fewer lifecycle mistakes, and a steadier long-term path for structured parallelism.