Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

nâng cấp java 25 LTS

Tung Bui Thanh (TungDaDev)17 mins read

Java25 upgrade.webp

OpenJDK ra Java 25 (bản LTS) ngày 16/9/2025, và câu hỏi quen thuộc lại tới: hệ thống đang chạy ngon trên Java 17 hay Java 21 thì có cần lên Java 25 không? Bỏ công test, chịu rủi ro tương thích, đổi lại được bao nhiêu (ROI), tính ra con số thế nào?

Ở vai Software Architect hay Principal Engineer, mình không nâng runtime chỉ vì thích cú pháp mới (syntactic sugar). Muốn nâng thì phải có lý do cụ thể:

  1. Tiết kiệm tài nguyên (Hardware Efficiency & Cloud Cost): tốn ít RAM hơn (heap footprint), nhét được nhiều pod hơn trên mỗi node Kubernetes.
  2. Độ trễ (P99/P99.9 Latency SLA): bớt các lần Stop-The-World (STW) của Garbage Collection khi tải lên hàng trăm nghìn QPS.
  3. Code an toàn, dễ bảo trì hơn (Safety & Maintainability): truyền context giữa các thread gọn hơn (Scoped Values; Structured Concurrency thì ở Java 25 vẫn còn preview), xử lý dữ liệu linh hoạt hơn (Stream Gatherers).

Phần dưới đi lần lượt từ tầng HotSpot JVM lên code ứng dụng. Số liệu benchmark chỉ lấy từ JEP chính thức, còn phần ROI là cách tính để bạn tự thay số của hệ thống mình vào.


# bản đồ tiến hóa java 17 sang java 25

Nhìn lại 3 bản LTS gần nhất cho dễ hình dung Java đã đi tới đâu:

Java 17 làm mới phần hướng đối tượng, Java 21 gỡ nút thắt I/O bằng Virtual Threads, còn Java 25 tập trung vào tầng JVM bên dưới: mỗi object tốn ít bộ nhớ hơn, Garbage Collector hoàn thiện hơn, xử lý stream dữ liệu linh hoạt hơn.

Một điều nên nhớ khi đọc phần dưới: nhiều tính năng "của Java 25" thật ra đã chốt ở bản non-LTS trước đó (Stream Gatherers chốt ở Java 24, Generational ZGC thành mặc định ở Java 23). Với team chỉ chạy bản LTS thì Java 25 là lần đầu được dùng chúng trên production, nên mình gom chung lại ở đây.


# jep 519: compact object headers

Hệ thống enterprise làm in-memory cache, xử lý dữ liệu lớn hay đồ thị mạng xã hội thường giữ cả trăm triệu object nhỏ trong heap. Trước Java 25, trên máy 64-bit có bật Compressed OOPs (-XX:+UseCompressedOops), object nào cũng phải cõng sẵn 12 bytes header:

  • Mark Word (8 bytes): chứa Identity HashCode, GC Age, trạng thái lock (biased locking đã bị gỡ hẳn từ Java 18).
  • Compressed Klass Pointer (4 bytes): con trỏ tới metadata của class trong Metaspace.

# cơ chế hoạt động của compact object headers

Java 25 gộp Mark Word và Klass Pointer lại thành một khối 64 bits (8 bytes):

  • Mark Word được sắp xếp lại, class pointer được nén còn 22 bit và nhét thẳng vào phần bit còn trống của Mark Word.
  • Tính năng này ra mắt ở Java 24 dạng experimental (JEP 450), lên Java 25 thì thành tính năng chính thức (JEP 519) nhưng vẫn tắt mặc định. Bật bằng -XX:+UseCompactObjectHeaders, không cần -XX:+UnlockExperimentalVMOptions nữa. Header giảm từ 12 bytes xuống 8 bytes.

Đánh đổi nằm ở chỗ 22 bit cho class pointer giới hạn số class JVM nạp được (khoảng 4 triệu, app bình thường còn xa mới chạm), và vài thao tác trên lock hay identity hash phải xử lý khéo hơn trong JVM. Đổi lại, theo JEP 519, benchmark SPECjbb2015 dùng ít hơn 22% heap và 8% CPU time, số lần GC giảm 15%; tính năng cũng đã được Amazon chạy thử trên hàng trăm service production (phần lớn qua bản backport cho JDK 17/21).

# tác động tới cache locality và chi phí cloud

Lấy ví dụ một microservice giữ 100_000_000 object OrderRef nhỏ trong bộ nhớ:

public class OrderRef {
    private final long orderId;   // 8 bytes
    private final int statusId;    // 4 bytes
    // Trước Java 25: 12B Header + 8B orderId + 4B statusId = 24B (aligned 8B)
    // Với Java 25: 8B Header + 8B orderId + 4B statusId = 20B -> aligned 24B
}

Để ý là OrderRef không nhỏ đi chút nào: 20 bytes vẫn bị làm tròn lên 24. Lợi ích thật phụ thuộc vào việc 4 bytes tiết kiệm được có "lọt" qua được mốc căn lề 8 bytes hay không.

Còn với object có field nhỏ hơn, như Point(int x, int y):

  • Trước Java 25: 12B header + 4B x + 4B y = 20B -> padding 8B alignment = 24 bytes.
  • Java 25 (bật cờ): 8B header + 4B x + 4B y = 16 bytes. Bớt 33.3% cho riêng loại object này.

Hai con số trên mình đã đo lại bằng JOL trên Temurin 25: OrderRef 24 → 24 bytes, Point 24 → 16 bytes. Heap thật gồm đủ loại object, mảng, String..., nên mức giảm chung thường thấp hơn, như con số 22% của SPECjbb2015 ở trên.

MetricJava 21 (12B Header)Java 25 (8B Header)Mức độ cải thiện
Heap cho 100M object kiểu Point2.4 GB1.6 GBTiết kiệm 800 MB (33.3%)
Heap SPECjbb2015 (theo JEP 519)BaselineGiảm 22%Workload thật, nhiều loại object
CPU time SPECjbb2015 (theo JEP 519)BaselineGiảm 8%Ít dữ liệu hơn, cache hit tốt hơn

TIP

Về chi phí cloud: nếu hoá đơn AWS EKS hay GCP GKE của bạn phình to chủ yếu vì RAM cho mấy cụm JVM, thì lên Java 25 rồi bật Compact Object Headers là cách khá rẻ để thử nhét thêm pod vào mỗi worker node, không phải sửa dòng business code nào. Nhớ là phải giảm -Xmx / request bộ nhớ của pod thì mới ra tiền, và đo heap sau GC trên chính service của bạn trước khi tính.


# jep 485: stream gatherers

Từ Java 8 tới giờ, java.util.stream.Stream chỉ có một số thao tác trung gian cố định (map, filter, flatMap, distinct, sorted). Khi cần mấy kiểu xử lý như:

  • Cắt stream thành từng cụm cố định (Fixed Windowing / Batching).
  • Cửa sổ trượt (Sliding Window).
  • Bỏ phần tử trùng với phần tử ngay trước (Deduplication against previous element).
  • Cộng dồn có trạng thái (Cumulative Sum / Stateful Scan).

thì đành bỏ Stream API, quay về vòng for-each viết tay, hoặc chịu viết code dính side-effect khó lường.

Java 24 chốt Stream Gatherers (JEP 485), và Java 25 là bản LTS đầu tiên có nó. Gatherer cho phép tự định nghĩa thao tác trung gian của riêng mình (Extensible Intermediate Operations):

package com.tungdadev.stream;
 
import java.util.List;
import java.util.stream.Gatherers;
import java.util.stream.Stream;
 
public class StreamGathererDemo {
 
    public static void main(String[] args) {
        List<Integer> transactions = List.of(10, 20, 30, 40, 50, 60, 70, 80, 90, 100);
 
        // 1. Fixed-size batching: Gom các phần tử thành từng đợt 3 phần tử
        List<List<Integer>> batches = transactions.stream()
                .gather(Gatherers.windowFixed(3))
                .toList();
        System.out.println("Batches: " + batches);
        // Output: [[10, 20, 30], [40, 50, 60], [70, 80, 90], [100]]
 
        // 2. Sliding window: Phân tích cửa sổ trượt kích thước 3
        List<List<Integer>> movingWindows = transactions.stream()
                .gather(Gatherers.windowSliding(3))
                .toList();
        System.out.println("Sliding: " + movingWindows);
        // Output: [[10, 20, 30], [20, 30, 40], [30, 40, 50], ...]
 
        // 3. Scan (Cumulative Fold): Tính tổng dồn qua từng phần tử
        List<Integer> cumulativeSums = transactions.stream()
                .gather(Gatherers.scan(() -> 0, (currentSum, nextVal) -> currentSum + nextVal))
                .toList();
        System.out.println("Cumulative Sums: " + cumulativeSums);
        // Output: [10, 30, 60, 100, 150, 210, 280, 360, 450, 550]
    }
}

Một Gatherer<T, A, R> gồm 4 hàm, na ná Collector, nhưng chạy ở tầng Intermediate:

  1. initializer(): tạo trạng thái ban đầu.
  2. integrator(): nhận từng phần tử mới, cập nhật trạng thái rồi đẩy kết quả xuống dưới (downstream push) hoặc dừng sớm (Greedy vs Short-circuiting).
  3. combiner(): gộp trạng thái khi chạy song song (Parallel Streams).
  4. finisher(): xả nốt những phần tử còn nằm trong buffer khi stream đầu vào đã hết.

# jep 513: flexible constructor bodies

Trước Java 25 có một luật cứng: super(...) hoặc this(...) phải là câu lệnh đầu tiên trong constructor. Luật này để tránh đụng vào trạng thái lớp con khi lớp cha chưa khởi tạo xong, nhưng nó gây khó chịu không ít:

Muốn validate tham số (fail-fast) hay chuẩn hoá dữ liệu trước khi truyền vào constructor lớp cha thì làm sao?

Trước giờ chỉ có cách viết mấy static helper method khá vụng:

// Cách cũ: Dễ gây rườm rà, khó đọc và khó debug
public class SecureOrderService extends BaseService {
    public SecureOrderService(String serviceKey, int timeoutMs) {
        super(validateAndSanitize(serviceKey), checkTimeout(timeoutMs));
    }
 
    private static String validateAndSanitize(String key) {
        if (key == null || key.isBlank()) throw new IllegalArgumentException("Key invalid");
        return key.trim().toLowerCase();
    }
 
    private static int checkTimeout(int timeout) {
        if (timeout <= 0) throw new IllegalArgumentException("Timeout must be > 0");
        return timeout;
    }
}

Có Flexible Constructor Bodies (preview từ Java 22 qua JEP 447, 482, 492, chốt ở Java 25 với JEP 513), bạn được chạy code chuẩn bị trước super(...) (phần này gọi là Prologue). Prologue không được đọc this, gọi method instance hay truyền this đi đâu, nhưng được gán giá trị cho field của chính class đang khởi tạo:

public class SecureOrderService extends BaseService {
    private final Instant initializedAt;
 
    public SecureOrderService(String serviceKey, int timeoutMs) {
        // PROLOGUE: Thực thi code trước super() hoàn toàn hợp lệ
        if (serviceKey == null || serviceKey.isBlank()) {
            throw new IllegalArgumentException("Service key must not be blank");
        }
        if (timeoutMs <= 0 || timeoutMs > 60_000) {
            throw new IllegalArgumentException("Timeout out of bounds: " + timeoutMs);
        }
 
        String sanitizedKey = serviceKey.trim().toLowerCase();
 
        // GỌI CONSTRUCTOR LỚP CHA
        super(sanitizedKey, timeoutMs);
 
        // EPILOGUE: Khởi tạo trạng thái lớp con sau super()
        this.initializedAt = Instant.now();
    }
}

Vậy là gỡ được cả hai chuyện:

  • Bỏ được mấy static method thừa trong class.
  • Gán field trước khi gọi super(...) giúp chặn một lỗi kinh điển: constructor lớp cha gọi một method bị lớp con override, method đó đọc field của lớp con khi field còn là null. Gán trong prologue thì lúc lớp cha gọi xuống, field đã có giá trị.

# tiến hóa garbage collection với generational ZGC

Với hệ thống có SLA gắt (P99.9 Latency < 10ms ở tải 50,000 TPS), Garbage Collection thường là thủ phạm số một gây jitter và latency spike.

# so sánh hiệu năng gc java 21 vs java 25

# Kích hoạt Generational ZGC trong Java 25 (từ Java 23 là mặc định khi dùng -XX:+UseZGC, Java 24 bỏ hẳn bản non-generational)
java -XX:+UseZGC \
     -XX:+UseCompactObjectHeaders \
     -Xms16g -Xmx16g \
     -Xlog:gc*,gc+phases=debug:file=/var/log/gc-java25.log:time,uptime,pid:filecount=5,filesize=100M \
     -jar payment-gateway.jar
Đặc tính kỹ thuậtG1GC (Java 21/25)Single-Gen ZGC (Java 21)Generational ZGC (Java 25)
Max Pause Time (STW)Mục tiêu mặc định 200ms (MaxGCPauseMillis), thực tế vài ms tới vài chục ms< 1ms< 1ms
CPU OverheadThấp nhất trong baCao nhất, mỗi chu kỳ đều đánh dấu toàn heapThấp hơn hẳn single-gen nhờ thu gom young thường xuyên
Tần suất Young CollectionTách biệt Young/OldKhông có young, quét toàn bộ Heap mỗi chu kỳTách biệt Young/Old
Khả năng trả RAM về OSĐịnh kỳ khi idle (JEP 346, Java 12+)Sau -XX:ZUncommitDelay (mặc định 300s)Như single-gen ZGC
Kích thước Heap phù hợpVài GB tới vài chục GB8MB - 16TB8MB - 16TB, lợi rõ nhất ở heap lớn

Cái hay của Generational ZGC là tận dụng Weak Generational Hypothesis (đa số object chết ngay sau khi được tạo). GC chỉ quét thường xuyên vùng Young Generation vốn nhỏ, nên đỡ tốn CPU của host, để dành CPU cho throughput của ứng dụng. Theo JEP 439, so với ZGC single-gen nó cần ít heap hơn và ít CPU hơn mà vẫn giữ pause dưới 1ms.

Phần lớn lợi ích này đã có từ Java 21 (bật thêm -XX:+ZGenerational). Nên nếu đang ở Java 21 thì GC không phải lý do chính để lên 25. Còn với G1, bài toán là đổi một ít throughput lấy tail latency: service nào P99.9 đang bị GC pause kéo lên thì mới đáng chuyển sang ZGC, còn batch job thì G1 hay Parallel vẫn hợp hơn. Đừng tin bảng nào (kể cả bảng trên) hơn số đo GC log của chính bạn.


# scoped values vs ThreadLocal trong virtual threads

Khi đưa Virtual Threads vào dùng rộng rãi trên Java 21, chỗ dễ vấp là ThreadLocal: nó được thiết kế cho vài trăm platform thread dùng lại lâu dài, không phải cho cả triệu virtual thread sống ngắn.

Tạo 1 triệu Virtual Threads mà thread nào cũng có ThreadLocal (SecurityContext, TraceId, TenantContext...) thì JVM phải cấp 1 triệu cái map riêng. Chưa kể ThreadLocal sửa được thoải mái, khó biết nó sống tới khi nào, dễ bị lẫn dữ liệu giữa các request.

Java 25 chuẩn hoá Scoped Values (JEP 506, trước đó preview qua JEP 446, 464, 481, 487): dữ liệu immutable, chỉ sống trong một khối code cụ thể, ra khỏi scope là tự biến mất.

Cũng nói cho công bằng: chỉ dùng ThreadLocal với virtual thread thì không leak, thread chết là map đi theo. Chi phí thật nằm ở InheritableThreadLocal (copy map mỗi lần tạo thread con) và ở chỗ giá trị sửa được, sống không rõ tới bao giờ. Scoped Value được thread con "thừa kế" mà không copy, nhưng chỉ khi thread con được tạo qua StructuredTaskScope (vẫn preview ở Java 25); tạo thread kiểu thường thì giá trị không đi theo.

package com.tungdadev.context;
 
import java.lang.ScopedValue;
 
public class SecurityContextHolder {
    // Định nghĩa ScopedValue toàn cục, bất biến
    public static final ScopedValue<UserPrincipal> CURRENT_USER = ScopedValue.newInstance();
    public static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
 
    public static void executeWithContext(UserPrincipal user, String traceId, Runnable action) {
        // Gắn giá trị vào Scope và thực thi hành động
        ScopedValue.where(CURRENT_USER, user)
                   .where(TRACE_ID, traceId)
                   .run(action);
        // Khi ra khỏi khối run(), context tự động biến mất khỏi stack frame
    }
 
    public static void processOrder() {
        if (CURRENT_USER.isBound()) {
            UserPrincipal user = CURRENT_USER.get();
            String traceId = TRACE_ID.get();
            System.out.printf("[%s] Processing order for user: %s%n", traceId, user.username());
        }
    }
}

# đánh giá roi và chiến lược migration

Lên Java 25 từ Java 17 hay 21 thì lợi về tiền khá rõ, nhưng phải chuẩn bị kỹ phần kỹ thuật.

# bảng phân tích roi

Hạng mục chi phí / Lợi íchJava 17 / 21Nâng cấp lên Java 25Tác động tài chính
Chi phí RAM hạ tầng KubernetesBaseline (VD: $50,000/tháng)Heap giảm tuỳ workload (SPECjbb2015: 22%)Giả sử RAM giảm được 10% thì tiết kiệm ~$5,000 / tháng; phải đo trên service thật
P99.9 Latency PenaltyPause G1 cỡ vài chục msPause dưới 1ms (Generational ZGC)Chỉ có lợi khi GC pause đang là nguyên nhân chính của tail latency
Thread Memory FootprintContext nằm trong ThreadLocal, khó kiểm soát vòng đờiScoped Values bất biến, rõ phạm viÍt lỗi lẫn context giữa các request, khó quy ra tiền
Chi phí bảo trì CodebaseXử lý Stream & Constructor dài dòngStream Gatherers, Flexible ConstructorsGiảm thời gian Onboarding và Bug rates

# các rủi ro tương thích cần lưu ý

  • Thư viện bytecode manipulation: mấy framework can thiệp sâu vào bytecode như ByteBuddy, ASM, CGLIB, Lombok phải lên bản hỗ trợ Class-File format của Java 25.
  • Bỏ port 32-bit x86: code cũ chạy trên phần cứng 32-bit sẽ không chạy được trên JDK 25.
  • Compact Object Headers: tuy khá ổn định, nhưng tool phân tích bộ nhớ dùng Unsafe hay thư viện đọc native offset của field thì nên test kỹ trước khi bật trên production.
  • Đi từ Java 17: Security Manager bị tắt hẳn từ Java 24 (JEP 486), các method truy cập bộ nhớ của sun.misc.Unsafe in cảnh báo từ Java 24 (JEP 498), agent nạp động lúc runtime bị cảnh báo từ Java 21 (JEP 451). Thư viện cũ hay đụng tới mấy thứ này (mock, APM agent, serialization) nên lên bản mới trước.

# tổng kết và khuyến nghị kiến trúc

Java 25 không phải bản cập nhật chạy theo tính năng cho vui. Nó nhắm vào mấy chuyện đau đầu nhất khi chạy cloud-native: chi phí bộ nhớ, tail latency và độ an toàn của code concurrency.

Nếu công ty bạn đang chạy cả trăm microservices trên Kubernetes và hoá đơn cloud mỗi tháng nhìn mà xót, thì lên Java 25 đáng để thử sớm. Cách làm an toàn: chọn vài service tốn RAM nhất, nâng lên 25, chạy song song một thời gian với cùng traffic, so heap sau GC, CPU và P99 trước và sau khi bật -XX:+UseCompactObjectHeaders, rồi mới quyết định có giảm request bộ nhớ của pod hay không. Có số đo của chính mình rồi thì bảng ROI ở trên mới thành con số thật.


Tài liệu tham khảo:


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 😎 👍🏻 🚀 🔥.