My answer on Java 25 is yes, but not as a blind runtime swap. I recommend treating it as the next serious LTS target for teams moving from Java 17 and as a planned evaluation for teams already stable on Java 21.
Assume an orders service. It receives a checkout request, carries tenant and trace context, reserves inventory, gets tax, calls a policy API, writes an invoice, encrypts export files, and needs enough profiling data to explain production latency. That is where Java 25 becomes practical.
Use Java 25 first where the runtime change can be measured: startup time, warmup time, p95 latency, CPU profile quality, heap footprint, request cancellation, and the amount of custom plumbing removed from application code.
Know which features need preview flags
When I talk about "Know which features need preview flags", I am looking for a measurable improvement in production code or runtime behavior.
Some useful pieces are final in Java 25, including scoped values, compact source files, module imports, flexible constructor bodies, the KDF API, and compact object headers as a product feature. Structured concurrency is still preview, so treat it like a deliberate engineering choice.
javac --release 25 --enable-preview CheckoutService.java
java --enable-preview CheckoutService
This is the first migration guard. If the service depends on preview APIs such as structured concurrency, make that visible in build and runtime commands so the team does not accidentally hide preview usage inside normal production code.
Scoped values replace risky request ThreadLocals
Scoped values are final in Java 25. They are useful when framework or infrastructure code needs to carry immutable request context through call chains and child work. The old habit is to put trace id, tenant, region, or authenticated subject in a ThreadLocal. That can leak across pooled threads if cleanup is missed. Scoped values bind the data to a visible execution scope.
import static java.lang.ScopedValue.where;
record RequestContext(String traceId, String tenantId, String region) {}
final class RequestScope {
private static final ScopedValue<RequestContext> CURRENT =
ScopedValue.newInstance();
static void runWith(RequestContext context, Runnable work) {
where(CURRENT, context).run(work);
}
static RequestContext current() {
return CURRENT.get();
}
}
void handleCheckout(CheckoutRequest request) {
var context = new RequestContext(
request.traceId(),
request.tenantId(),
request.region()
);
RequestScope.runWith(context, () -> {
audit("checkout.started", RequestScope.current().traceId());
orderService.createOrder(request, RequestScope.current().tenantId());
});
}
The request context is readable by code called inside the scope, but it cannot be changed from far away with a setter. That matters in virtual thread services because context should follow the request, not the physical carrier thread.
Structured concurrency makes fan out work accountable
The checkout service has to call inventory and tax before it can quote the order. With unstructured futures, it is easy to leave work running after the parent request has already failed. Structured concurrency gives the parent request a clear boundary for child tasks.
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
Quote quote(CheckoutRequest request) throws Exception {
try (var scope = StructuredTaskScope.open()) {
Subtask<InventoryHold> inventory =
scope.fork(() -> inventoryClient.reserve(request.items()));
Subtask<TaxQuote> tax =
scope.fork(() -> taxClient.quote(request.shipTo(), request.items()));
scope.join();
return new Quote(inventory.get(), tax.get());
}
}
The code says the inventory and tax calls belong to the quote operation. If the parent request is cancelled or a child fails, the failure is handled at the same boundary that created the work. That is a cleaner model for service fan out than scattered futures.
The HTTP client can stop oversized responses
My recommendation in "The HTTP client can stop oversized responses" is to test Should You Update to Java 25 on one representative service before standardizing it.
JDK 25 adds limiting body handlers and subscribers to the HTTP client. That sounds small until a partner API returns a bad response and your service quietly accepts a 40 MB payload into memory. In production, the useful default is to cap what you are willing to read.
var request = HttpRequest.newBuilder(policyUri)
.timeout(Duration.ofSeconds(2))
.GET()
.build();
var limitedBody = HttpResponse.BodyHandlers.limiting(
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8),
512_000
);
HttpResponse<String> response = httpClient.send(request, limitedBody);
if (response.statusCode() != 200) {
throw new PolicyServiceException(response.statusCode());
}
The service caps the response at 512 KB before parsing it. That is a concrete production safety decision: a broken dependency should fail the request clearly instead of turning into heap pressure or a slow memory leak investigation.
KDF gives crypto code a standard place to live
Java 25 introduces a standard Key Derivation Function API. The first included algorithm is HKDF. This is useful for teams that derive per tenant or per file keys and want less provider specific glue code.
import java.nio.charset.StandardCharsets;
import java.security.spec.AlgorithmParameterSpec;
import javax.crypto.KDF;
import javax.crypto.SecretKey;
import javax.crypto.spec.HKDFParameterSpec;
SecretKey deriveInvoiceExportKey(byte[] sharedSecret,
byte[] tenantSalt,
String tenantId) throws Exception {
KDF hkdf = KDF.getInstance("HKDF-SHA256");
AlgorithmParameterSpec params =
HKDFParameterSpec.ofExtract()
.addIKM(sharedSecret)
.addSalt(tenantSalt)
.thenExpand(
("invoice-export:" + tenantId).getBytes(StandardCharsets.UTF_8),
32
);
return hkdf.deriveKey("AES", params);
}
The important part is the boundary. Key derivation is no longer hidden in hand rolled byte array code. The tenant id becomes explicit input to key expansion, and reviewers can inspect the KDF algorithm, salt, context string, and output length in one place.
Compact files help support tools, not only beginners
Compact source files and module imports are easy to dismiss as teaching features. I would use them for small operational tools: log scanners, data repair dry runs, one time migration checks, and release verification scripts that should stay in Java instead of drifting into five shell languages.
import module java.base;
void main(String[] args) throws Exception {
var logFile = Path.of(args[0]);
var orderId = args[1];
try (var lines = Files.lines(logFile)) {
lines.filter(line -> line.contains(orderId))
.filter(line -> line.contains("PAYMENT_DECLINED"))
.limit(20)
.forEach(IO::println);
}
}
This is a real support task: find the first declined payment entries for one order id. Java 25 keeps the file small without inventing a separate scripting stack, and the same code can grow into a normal class if the tool becomes permanent.
Flexible constructors remove awkward validation
What I learnt around "Flexible constructors remove awkward validation" is that runtime changes need benchmarks, rollback, and real workload evidence.
Flexible constructor bodies allow safe statements before an explicit super(...) or this(...) call. That is useful when a constructor needs to validate or normalize data before building the superclass message.
final class OrderRejectedException extends RuntimeException {
OrderRejectedException(String orderId, String reason) {
var cleanOrderId = requireOrderId(orderId);
super("orderId=" + cleanOrderId + ", reason=" + reason);
}
private static String requireOrderId(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("orderId is required");
}
return value.trim();
}
}
Before Java 25, this kind of code often moved into helper factories or repeated validation outside the constructor. The newer form keeps the invariant close to the exception that depends on it.
AOT cache support is worth testing on cold starts
Java 25 improves the command line flow for creating AOT caches and adds method profiling data to the AOT cache. The practical target is startup and warmup, especially for jobs, serverless style workloads, small services that scale often, and command line tools.
java -XX:AOTCacheOutput=orders.aot -jar orders.jar --train checkout-smoke
java -XX:AOTCache=orders.aot -jar orders.jar
The first command performs a training run and writes the cache. The second starts the service with that cache. Do not declare victory from the flag alone; compare cold start, warmup, p95 latency during the first few minutes, and cache build cost.
JFR is a stronger production debugger
I use "JFR is a stronger production debugger" to separate useful platform movement from release note excitement.
JDK 25 improves JFR with CPU time profiling on Linux, cooperative sampling, and method timing and tracing. This matters when the question is not "did the JVM run?" but "where did this request spend CPU, which method is slow, and can we collect the data without destabilizing the process?"
java -XX:StartFlightRecording=jdk.CPUTimeSample#enabled=true,filename=orders.jfr \
-jar orders.jar
jfr view cpu-time-hot-methods orders.jfr
jcmd $PID JFR.start method-timing=@com.example.SlowPath \
duration=4m filename=slow-path.jfr
The first recording asks JFR for CPU time samples. The method timing command narrows attention to code marked for investigation. That gives teams a repeatable way to prove whether a suspected method is actually expensive.
Compact object headers are a measured memory option
Compact object headers move from experimental to product feature in Java 25, but they are not the default. The right move is to test them on object heavy services. OpenJDK cites experiments such as SPECjbb2015 using 22 percent less heap space and 8 percent less CPU time in one setting, but your service still needs its own measurement.
java -XX:+UseCompactObjectHeaders -Xlog:gc* -jar orders.jar
This is an opt in runtime test. Compare heap usage, GC count, pause time, CPU, and business latency against the same load without compact object headers before adopting it as a platform default.
What I would not rush
The way I apply "What I would not rush" is to look for simpler code first and nicer syntax second.
Java 25 also includes preview or incubating work such as stable values, primitive types in patterns, the Vector API, PEM encodings, and structured concurrency. These are useful, but not all of them should drive an immediate production migration. Use preview features only when the team accepts the compile and runtime flags, test coverage, and future change risk.
So yes, update to Java 25, but make the upgrade earn its place. The strongest case is not "new LTS." The strongest case is a short list of measurable improvements: cleaner context handling, accountable fan out, bounded HTTP responses, standard key derivation, faster startup and warmup, better JFR evidence, and lower memory footprint where compact object headers fit.