java 25 LTS
Tung Bui Thanh (TungDaDev)14 mins read

Java 25 (LTS) went GA on September 16, 2025. Since the previous LTS (21), a lot has landed: some features first appeared as previews or experiments in 22 to 24 and are final in 25, so if you jump from 21 straight to 25 you get all of them at once. It is more than syntax sugar: it changes how HotSpot lays objects out in memory, adds an extension point to stream processing, and gives you a safer way to share per-request context with virtual threads.
This post goes through the ones that matter most for backend services: how they work inside the JVM, what they mean for memory and latency, and what to check when you migrate. Where a feature was finalized before 25 (Stream Gatherers in 24, Generational ZGC as the only ZGC mode in 24), I say so.
# kiến trúc tổng quan java 25
# jep 450: compact object headers
# chi phí 12-byte header truyền thống
On 64-bit HotSpot with compressed class pointers (the default), every Java object carries a 12-byte header before any fields:
- Mark Word (8 bytes / 64 bits): identity hash code, GC age, lock state (thin lock or pointer to an inflated monitor) and GC metadata. Older JDKs also stored biased-locking bits here; biased locking was removed in JDK 18.
- Compressed class pointer (4 bytes / 32 bits): points to the class metadata (
Klass) in Metaspace.
Objects are aligned to 8 bytes, so a 12-byte header leaves a 4-byte gap that only a 4-byte (or smaller) field can fill; otherwise it becomes padding. JEP 450 notes that many workloads have an average object size of 32 to 64 bytes, which means more than 20% of live data can be headers alone.
# hotspot nén header xuống 8-byte như thế nào
Compact Object Headers shipped as an experimental feature in JDK 24 (JEP 450) and became a product feature in JDK 25 (JEP 519). The header shrinks to a single 64-bit (8-byte) word:
- The class pointer is squeezed from 32 bits into 22 bits and moved into the mark word. 22 bits still address about 4 million classes, far more than any real application loads.
- The remaining bits hold the identity hash code, GC age and lock bits as before, plus four bits reserved for Project Valhalla.
- It is still off by default in JDK 25. Turn it on with
-XX:+UseCompactObjectHeaders; noUnlockExperimentalVMOptionsneeded anymore. - With it on, a small class like
record Point(int x, int y)drops from 24 bytes (12 header + 8 fields + 4 padding) to 16 bytes (8 header + 8 fields), a 33% reduction for that object.
That 33% is the best case for tiny objects. Bigger objects gain proportionally less, and arrays of primitives gain almost nothing relative to their size.
# định lượng dung lượng heap tiết kiệm
Consider an in-memory cache retaining N = 50,000,000 telemetry readings:
Memory_legacy = 50,000,000 * 24 bytes = 1.20 GB
Memory_Java25 = 50,000,000 * 16 bytes = 0.80 GB
Heap saved = 400 MB (33% for these tiny objects)Beyond raw heap footprint, smaller objects mean more of them fit in each CPU cache line and fewer GC cycles are needed. The numbers published in JEP 519 are a more realistic expectation than the toy calculation above: on SPECjbb2015, 22% less heap and 8% less CPU time; about 15% fewer GC cycles with G1 and Parallel; and early adopters typically saw live data shrink by 10% to 20%. Amazon ran it in production across hundreds of services (including backports to 17 and 21) before it became a product feature.
TIP
Khuyến nghị cho production: Bật -XX:+UseCompactObjectHeaders trên Staging/Canary trước, so heap sau GC, số lần GC và CPU với bản cũ. Heap nhỏ đi bao nhiêu phụ thuộc vào kích thước object trung bình của ứng dụng (thường 10-20% live data theo JEP), và nhớ rằng memory của container còn gồm Metaspace, thread stack, direct buffer, nên mức tiết kiệm trên tổng RAM của Pod sẽ thấp hơn mức tiết kiệm trên heap.
# jep 485: stream gatherers
Since Java 8, java.util.stream.Stream let you write custom terminal operations via Collector<T, A, R>, but the set of intermediate operations (map, filter, flatMap, ...) was fixed. Anything stateful like windowing or "drop consecutive duplicates" meant collecting into a list first or writing awkward flatMap tricks.
JEP 485 (Stream Gatherers), previewed in 22 and 23 and final since JDK 24, adds an extension point for intermediate operations: stream.gather(gatherer). A gatherer can keep state, emit zero, one or many elements per input, and stop the stream early:
# các gatherer có sẵn trong java.util.stream.Gatherers
package com.tungdadev.streams;
import java.util.List;
import java.util.stream.Gatherers;
public class GathererShowcase {
public static void main(String[] args) {
List<String> events = List.of("login", "view", "cart", "checkout", "logout", "rate");
// 1. Fixed-size batching: Chunk streams into sub-lists
List<List<String>> batches = events.stream()
.gather(Gatherers.windowFixed(2))
.toList();
// Output: [[login, view], [cart, checkout], [logout, rate]]
// 2. Sliding-window analytics: Calculate moving pairs
List<List<String>> slidingPairs = events.stream()
.gather(Gatherers.windowSliding(2))
.toList();
// Output: [[login, view], [view, cart], [cart, checkout], [checkout, logout], [logout, rate]]
// 3. Stateful sequential scanning
List<Integer> values = List.of(1, 2, 3, 4, 5);
List<Integer> runningTotals = values.stream()
.gather(Gatherers.scan(() -> 0, Integer::sum))
.toList();
// Output: [1, 3, 6, 10, 15]
}
}# tự viết custom gatherer trong production
You can write custom gatherers for stateful deduping, rate limiting or dynamic batching. This one drops consecutive duplicates (a, a, b, a becomes a, b, a):
import java.util.Objects;
import java.util.function.Supplier;
import java.util.stream.Gatherer;
public class DistinctConsecutiveGatherer<T> implements Gatherer<T, DistinctConsecutiveGatherer.State<T>, T> {
static class State<T> {
boolean hasLast = false; // a flag, so null elements are handled correctly
T lastElement;
}
@Override
public Supplier<State<T>> initializer() {
return State::new;
}
@Override
public Integrator<State<T>, T, T> integrator() {
// ofGreedy: this gatherer never stops the stream early on its own
return Integrator.ofGreedy((state, element, downstream) -> {
if (!state.hasLast || !Objects.equals(state.lastElement, element)) {
state.hasLast = true;
state.lastElement = element;
return downstream.push(element);
}
return true; // continue without pushing duplicate
});
}
}Use it as stream.gather(new DistinctConsecutiveGatherer<>()). Because it does not override combiner(), the stream runs this stage sequentially even on a parallel stream, which is what you want for an order-dependent operation.
# jep 482: flexible constructor bodies
Java's strict requirement that super(...) or this(...) must be the first statement in a constructor caused decades of clunky workarounds. Developers frequently resorted to static helper methods or auxiliary constructors just to validate or transform arguments before delegating to the parent.
Flexible Constructor Bodies went through several previews (JEP 447, 482, 492) and are final in JDK 25 as JEP 513. You may now put statements before super(...) or this(...) (the prologue). The prologue cannot use the object being built: no reading fields, no calling instance methods, no passing this anywhere. It can assign the class's own fields, though:
public class SecurePaymentGateway extends BasePaymentProvider {
private final String clientToken;
private final Instant createdAt;
public SecurePaymentGateway(String merchantId, String secretKey, int timeoutMs) {
// PROLOGUE: Fail-fast validation and argument sanitation BEFORE super()
if (merchantId == null || merchantId.isBlank()) {
throw new IllegalArgumentException("Merchant ID cannot be empty");
}
if (timeoutMs < 100 || timeoutMs > 30_000) {
throw new IllegalArgumentException("Timeout out of acceptable range [100ms - 30s]");
}
String normalizedMerchant = merchantId.trim().toUpperCase();
String hashedKey = CryptographyEngine.sha256(secretKey);
// Assigning (not reading) own fields is allowed in the prologue
this.clientToken = UUID.randomUUID().toString();
// EXPLICIT SUPER CALL: Clean, validated parameters passed upstream
super(normalizedMerchant, hashedKey, timeoutMs);
// EPILOGUE: the rest of the instance state
this.createdAt = Instant.now();
}
}(BasePaymentProvider and CryptographyEngine are placeholders; imports for Instant and UUID omitted.)
Two things improve. Validation fails fast before the superclass does any work, without the old static helper trick. And assigning clientToken before super(...) fixes a classic bug: if the superclass constructor calls an overridable method, the subclass override used to see its fields still null. Now they can be initialized first.
# scoped values vs ThreadLocal
Virtual Threads (final in Java 21) make it cheap to run hundreds of thousands of concurrent tasks. ThreadLocal still works on them, but JEP 506 lists the design flaws that become more painful at that scale:
- Unconstrained mutability: any code that can
get()can alsoset(), so data flow is hard to follow, and state can leak to the next request if someone forgetsremove()(a real problem with pooled platform threads). - Unbounded lifetime: a value lives as long as the thread unless removed explicitly.
- Expensive inheritance:
InheritableThreadLocalcopies the parent's map into every child thread. With a million virtual threads, a million per-thread copies of request context adds up.
Scoped Values (final in JDK 25 as JEP 506, after incubating in 20 and previews in 21 to 24) bind an immutable value for the duration of a call. Child threads created inside a StructuredTaskScope can read the same binding without copying.
# áp dụng ScopedValue trong production
package com.tungdadev.concurrency;
import java.lang.ScopedValue;
public class RequestPipeline {
public static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();
public record RequestContext(String traceId, String tenantId, long timestamp) {}
public void handleIncomingRequest(String traceId, String tenantId, Runnable task) {
RequestContext ctx = new RequestContext(traceId, tenantId, System.currentTimeMillis());
// Bind context immutably to the execution frame
ScopedValue.where(CONTEXT, ctx)
.run(task);
// Automatically unbound once run() completes
}
public void executeServiceLogic() {
if (CONTEXT.isBound()) {
RequestContext current = CONTEXT.get();
System.out.printf("[Trace: %s] Processing for Tenant: %s%n",
current.traceId(), current.tenantId());
}
}
}# generational ZGC chuẩn hóa
Generational ZGC arrived in JDK 21 (JEP 439) behind -XX:+ZGenerational, became the default ZGC mode in JDK 23 (JEP 474), and the old non-generational mode was removed in JDK 24 (JEP 490). So on Java 25, -XX:+UseZGC always means generational ZGC; -XX:+ZGenerational is ignored with a warning. Note that the default collector is still G1, you have to opt into ZGC.
# Recommended Enterprise Production JVM Flags for Java 25
java -XX:+UseZGC \
-XX:+UseCompactObjectHeaders \
-Xms16g -Xmx16g \
-XX:SoftMaxHeapSize=12g \
-Xlog:gc*,gc+phases=info:file=/var/log/app/gc-%t.log:time,uptime,pid:filecount=10,filesize=50M \
-jar cloud-enterprise-service.jar-Xms equal to -Xmx reserves and commits the whole heap up front, which keeps latency predictable. The trade-off: ZGC never uncommits memory below -Xms, so -XX:ZUncommitDelay has no effect in this setup. If you want the heap to give memory back to the OS when idle, set -Xms lower. SoftMaxHeapSize asks ZGC to try to stay under 12 GB and only grow toward 16 GB when needed.
# benchmark so sánh hiệu năng GC
There is no single benchmark number that holds across workloads, so the table below is qualitative: it summarizes the design goals in the JEPs and the trends reported in public write-ups, not a measurement. Measure your own service before and after.
| Metric | G1GC (Java 21) | Single-Gen ZGC (Java 21) | Generational ZGC (Java 25) |
|---|---|---|---|
| Max STW Pause Time | Tens of ms, grows with young gen | Under 1 ms | Under 1 ms |
| Average STW Pause Time | Several ms | Well under 1 ms | Well under 1 ms |
| P99.9 Application Latency | GC pauses often show up in the tail | Pauses rarely matter; allocation stalls can | Fewer allocation stalls than single-gen |
| Collector CPU Overhead | Low, throughput-oriented | Higher: whole heap marked every cycle | Lower than single-gen (young objects die young) |
| Memory Reclamation Delay | Moderate | Concurrent, needs heap headroom | Concurrent, needs less headroom |
Single-generation ZGC had to mark the whole heap on every cycle, even though most objects die young. Generational ZGC splits the heap into young and old generations and collects the young one much more often, so it does less work per cycle and needs less spare heap to avoid allocation stalls. To make that work it added store barriers (to track old-to-young pointers) on top of the colored pointers and load barriers ZGC already had. JEP 439 reports lower memory overhead and CPU usage than single-generation ZGC while keeping pauses under a millisecond; Netflix wrote that they switched their default from G1 to Generational ZGC on JDK 21+, seeing better average and P99 latency with equal or better CPU (Bending pause times to your will with Generational ZGC).
TIP
Gần như không cần tuning: Generational ZGC tự điều chỉnh kích thước young/old theo tốc độ cấp phát thực tế, không có (và không cần) NewRatio hay cờ chỉnh young gen như các GC cũ. Thứ quan trọng nhất cần chỉnh là -Xmx: cho đủ headroom để GC chạy song song kịp với tốc độ cấp phát, tránh allocation stall.
# lộ trình migration sang java 25
To upgrade from Java 17/21 to Java 25 smoothly across a fleet of microservices:
- Bytecode Toolchain Compatibility: Gradle 9.1.0+ is the first version that runs on and supports a Java 25 toolchain; Maven Compiler Plugin 3.13+ works with
<release>25</release>. Update bytecode-manipulating dependencies: Byte Buddy 1.17.5+ (pulled in by Mockito and Hibernate), Lombok 1.18.40+, and a Spring Boot line that supports Java 25 (a recent 3.5.x patch or 4.x). - Observe Compact Object Header Effects: Check that tools or native code that compute object layout (for example
Unsafefield offsets, or anything assuming a 12-byte header) still work. Normal Java code is unaffected. - Audit ThreadLocal Usages: Find thread-local request context in code that runs on virtual threads and consider replacing it with
ScopedValue. Libraries such as SLF4J's MDC still useThreadLocal, so this is a gradual move, not a rewrite.
# tổng kết kiến trúc
If you are on 21, the cheapest win is to upgrade the toolchain and try -XX:+UseCompactObjectHeaders on a canary: no code change, measurable heap savings. Stream Gatherers, Flexible Constructor Bodies and Scoped Values are language and library features you can adopt gradually as you touch code. Structured Concurrency is still a preview in 25, so keep it out of production code for now.
References:
- OpenJDK JDK 25 Project Specification
- JEP 519: Compact Object Headers (and JEP 450, the experimental version in JDK 24)
- JEP 485: Stream Gatherers
- JEP 513: Flexible Constructor Bodies
- JEP 506: Scoped Values
- JEP 505: Structured Concurrency (Fifth Preview)
- JEP 439: Generational ZGC and JEP 490: ZGC: Remove the Non-Generational Mode
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.