JDK 25 is a Long-Term Support release — the Java version that enterprise teams will standardize on for the next several years. What makes an LTS worth understanding in depth is not just the feature list but what the feature list says about Java’s direction. JDK 25 is the release where Project Loom’s two remaining major components — Structured Concurrency and Scoped Values — leave the preview cycle and become fully supported production APIs. It is also the release where several years of incremental improvements to pattern matching, the class-file API, and virtual thread scheduling arrive as a stable, coherent platform. This post examines what changed, why it matters, and where the risks are in migration.
Structured Concurrency: From Preview to Production
Structured Concurrency (JEP 505) was in preview for six consecutive releases before being finalized in JDK 25. The long preview cycle was intentional — the API needed to stabilize under real-world usage patterns before becoming a long-term commitment. The core concept is straightforward: when you fork multiple tasks, those tasks are treated as a unit. If one fails, the others are cancelled. When the scope exits, all forks have either completed or been cancelled. You cannot leak a subtask past its enclosing scope.
The practical consequence is that the concurrency structure of your code becomes visible from its call-site structure, which is not true of ExecutorService. With a thread pool, a submitted task can outlive the method that submitted it — it is on the pool until it completes. With a StructuredTaskScope, the lifetime is bounded by the try-with-resources block. The JVM enforces this: the scope cannot exit while forks are still running.
// Java 25+ — Structured Concurrency finalized; ShutdownOnFailure cancels remaining forks if any fails
import jdk.incubator.concurrent.StructuredTaskScope;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
public class OrderEnrichmentService {
private static final Logger log = LoggerFactory.getLogger(OrderEnrichmentService.class);
// Two blocking JDBC calls run in parallel; scope cancels both if either fails
public EnrichedOrder enrich(String orderId, javax.sql.DataSource ds) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Fork two independent database queries — each runs on a virtual thread
var orderFork = scope.fork(() -> loadOrder(orderId, ds));
var customerFork = scope.fork(() -> loadCustomer(orderId, ds));
// Block until both complete or one fails
scope.join(); // waits for all forks
scope.throwIfFailed(); // rethrows first failure, cancelling siblings automatically
// Both succeeded — resultNow() is safe after throwIfFailed()
Order order = orderFork.resultNow();
Customer customer = customerFork.resultNow();
log.info("Enriched order {} for customer {}", orderId, customer.id());
return new EnrichedOrder(order, customer);
} // scope.close() cancels any still-running forks — no leaks
}
private Order loadOrder(String orderId, javax.sql.DataSource ds) throws Exception {
try (Connection conn = ds.getConnection();
PreparedStatement ps = conn.prepareStatement(
"SELECT id, amount, status FROM orders WHERE id = ?")) {
ps.setString(1, orderId);
ResultSet rs = ps.executeQuery();
if (rs.next()) return new Order(rs.getString("id"), rs.getLong("amount"));
throw new IllegalArgumentException("Order not found: " + orderId);
}
}
private Customer loadCustomer(String orderId, javax.sql.DataSource ds) throws Exception {
try (Connection conn = ds.getConnection();
PreparedStatement ps = conn.prepareStatement(
"SELECT c.id, c.name FROM customers c JOIN orders o ON c.id = o.customer_id WHERE o.id = ?")) {
ps.setString(1, orderId);
ResultSet rs = ps.executeQuery();
if (rs.next()) return new Customer(rs.getString("id"), rs.getString("name"));
throw new IllegalArgumentException("Customer not found for order: " + orderId);
}
}
record Order(String id, long amount) {}
record Customer(String id, String name) {}
record EnrichedOrder(Order order, Customer customer) {}
}Code language: Java (java)
Scoped Values: Replacing ThreadLocal for Context Propagation
Scoped Values (JEP 506), also finalized in JDK 25, address a specific and recurring problem: how do you pass contextual information — a request ID, a user principal, a tenant identifier — through a call chain without threading it through every method signature? ThreadLocal was the traditional answer. It works under the platform thread pool model because threads are reused across requests, and clearing the ThreadLocal between requests is manageable. With virtual threads, where each task gets its own thread and thread count is effectively unbounded, ThreadLocal’s memory and lifecycle characteristics become a liability.
Scoped Values are immutable for the duration of a scope, propagate automatically into child scopes (including StructuredTaskScope forks), and are cleaned up automatically when the scope exits. They cannot be mutated inside the scope — if you need a different value for a sub-operation, you open a new scope with the new value, which is visible only within that sub-scope. This makes the context flow explicit and safe.
// Java 25+ — ScopedValue propagates immutable context; automatically cleaned up at scope exit
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jdk.incubator.concurrent.ScopedValue;
import jdk.incubator.concurrent.StructuredTaskScope;
public class RequestPipeline {
private static final Logger log = LoggerFactory.getLogger(RequestPipeline.class);
// Declare scoped values as static constants — never set them directly
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
static final ScopedValue<String> TENANT_ID = ScopedValue.newInstance();
public void handleRequest(String reqId, String tenantId) throws Exception {
// ScopedValue.where().run() — values are bound for the duration of the lambda
ScopedValue.where(REQUEST_ID, reqId)
.where(TENANT_ID, tenantId)
.run(() -> processRequest());
// After run() returns, REQUEST_ID and TENANT_ID are unbound — no cleanup needed
}
private void processRequest() throws Exception {
// Anywhere in the call chain, retrieve the value without passing it as a parameter
log.info("Processing request {} for tenant {}", REQUEST_ID.get(), TENANT_ID.get());
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// ScopedValue automatically propagates into forked virtual threads
scope.fork(() -> {
log.debug("Subtask running in request context: {}", REQUEST_ID.get());
return queryDatabase();
});
scope.join();
scope.throwIfFailed();
}
}
private String queryDatabase() {
// ScopedValue accessible here, inside a fork, without being passed as a parameter
log.debug("DB query for tenant: {}", TENANT_ID.get());
return "result";
}
}Code language: Java (java)
Stream Gatherers: Custom Intermediate Operations
Stream Gatherers (JEP 485), which arrived in JDK 24 and are fully available in JDK 25, fill a long-standing gap in the Streams API. The existing intermediate operations — filter, map, flatMap, limit, sorted — cover common patterns but do not compose into arbitrary stateful transformations. If you needed sliding windows, batching, deduplication with state, or any operation that required tracking elements across positions in the stream, you had to terminate the stream, process in a loop, and reconstruct. Gatherers allow you to define custom intermediate operations that maintain state across elements.
// Java 25+ — Stream Gatherers for stateful intermediate operations
import java.util.stream.Gatherers;
import java.util.stream.Stream;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class MetricsAggregator {
private static final Logger log = LoggerFactory.getLogger(MetricsAggregator.class);
public void analyzeMetrics(Stream<Double> cpuReadings) {
// Gatherers.windowSliding(n) produces overlapping windows of n elements
List<List<Double>> windows = cpuReadings
.gather(Gatherers.windowSliding(5)) // [0-4], [1-5], [2-6], ...
.toList();
// Compute moving average over each window
windows.stream()
.map(window -> window.stream().mapToDouble(Double::doubleValue).average().orElse(0))
.filter(avg -> avg > 80.0) // Alert on windows above 80% CPU
.forEach(avg -> log.warn("High CPU window detected: {:.1f}%", avg));
}
public void batchProcess(Stream<String> eventStream) {
// Gatherers.windowFixed(n) produces non-overlapping fixed-size batches
eventStream
.gather(Gatherers.windowFixed(100)) // Batch into groups of 100
.forEach(batch -> {
log.info("Processing batch of {} events", batch.size());
insertBatch(batch); // Send as one JDBC batch insert
});
}
private void insertBatch(List<String> batch) { /* JDBC batch insert */ }
}Code language: Java (java)
Gatherers also support custom implementations via Gatherer.of(), which lets you define initializer, integrator, combiner, and finisher components — the same structure as a Collector but for intermediate operations. This enables stateful transformations like running totals, state machine transitions over a stream, or conditional accumulation that was previously only expressible by terminating and re-streaming.
Primitive Types in Patterns
Pattern matching in Java 25 extends to primitive types. Previously, type patterns in instanceof and switch only worked with reference types. If the checked value was an int or long, you needed explicit comparison or boxing. JDK 25 allows primitive type patterns in switch, enabling uniform dispatch regardless of whether the type is primitive or reference. This matters most in switch expressions that mix primitive values with object types, or in systems that receive untyped data and need to dispatch based on value type.
// Java 25+ — primitive type patterns in switch; no boxing required
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class MetricDispatcher {
private static final Logger log = LoggerFactory.getLogger(MetricDispatcher.class);
// Dispatch based on type of a value — works for both primitives and references
public void record(Object value) {
switch (value) {
case Integer i -> log.info("Integer metric: {}", i);
case Long l -> log.info("Long metric: {}", l);
case Double d -> log.info("Double metric: {}", d);
case String s -> log.info("Named metric: {}", s);
case null -> log.warn("Received null metric — ignored");
default -> log.warn("Unrecognised metric type: {}", value.getClass());
}
}
}Code language: Java (java)
LTS Migration Decision: JDK 17 to JDK 25
JDK 17 has been the dominant LTS since 2021. Most enterprise teams on JDK 17 will face a migration decision in the JDK 25 cycle. The path is not as large a jump as JDK 8 to JDK 17 was. Sealed classes, records, and pattern matching arrived in JDK 17 and have been stable. JDK 21 added virtual threads, which are the highest-value single feature in recent Java history for I/O-heavy applications. JDK 25 finalizes Structured Concurrency and Scoped Values, making the Loom concurrency model complete.
The concrete migration risks are: libraries that use internal JVM APIs removed since JDK 17 (check with jdeps --jdk-internals before upgrading), synchronized blocks in I/O paths that pin virtual threads (run with -Djdk.tracePinnedThreads=full to detect), and ThreadLocal-heavy frameworks that do not yet handle the per-task virtual thread model well. Each of these is detectable before cutover and fixable without rewriting application logic.
Interview Questions
What happens if a fork inside a StructuredTaskScope throws an unchecked exception before scope.join() is called?
The fork’s exception is captured internally by the scope. With ShutdownOnFailure, the scope’s shutdown policy triggers: all other running forks receive an interrupt signal. When scope.join() completes and scope.throwIfFailed() is called, the first exception (or the one the policy selected) is rethrown. The parent thread does not see the exception until it explicitly calls throwIfFailed(). This is by design — the structured approach allows all running forks to acknowledge cancellation before the exception propagates, rather than propagating immediately and leaving sibling tasks in an unknown state.
How does a ScopedValue behave when a new binding is created inside an already-bound scope?
The inner binding shadows the outer one within its scope. If REQUEST_ID is bound to "outer" and a nested ScopedValue.where(REQUEST_ID, "inner").run() is called inside, code within the inner run sees "inner". When the inner run returns, the binding reverts to "outer". The outer binding is never mutated — this is the immutability guarantee that makes ScopedValues safe to propagate into concurrent forks without synchronization.
What issues arise when adopting Structured Concurrency in a Spring Boot application where service methods are annotated with @Transactional?
Spring’s @Transactional uses a ThreadLocal to propagate the transaction context to the current thread. Forks created inside a StructuredTaskScope run on different virtual threads, which do not inherit ThreadLocal values from the parent. Database calls inside forks will not participate in the parent’s transaction and will either obtain their own connection (outside the transaction) or fail if the DataSource requires a transactional context. The fix is to avoid forking transactional work across scope boundaries — keep each fork responsible for its own transaction, or fetch the data inside forks and do the transactional write in the parent thread after scope.join().
Summary
JDK 25 is the LTS release that completes Project Loom. Structured Concurrency gives you safe, leak-proof parallel task management where the code structure reflects the task lifetime structure. Scoped Values give you immutable, automatically-propagated context that works correctly with virtual thread forks. Stream Gatherers fill the gap in the Streams API for stateful intermediate transformations. For teams on JDK 17, migration to JDK 25 should be evaluated now — the operational risks are detectable and bounded, and the concurrency model improvements represent a significant productivity and reliability gain for I/O-heavy services.




