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

Explore
MikeDaDev's Blog
in java

Generational ZGC trong java 21

Tung Bui Thanh (TungDaDev)9 mins read

Brown concrete building

Scale hệ thống Java lớn, dân backend sợ nhất là GC Pause: mọi thread ứng dụng đứng im (Stop-The-World), P99 latency vọt lên trời, health check báo timeout. Generational ZGC sinh ra để chuyện này thành dĩ vãng.

1. Cái khổ mang tên Garbage Collection Pauses

Với hệ thống phân tán chịu tải cao, P99 và P99.9 latency mới phản ánh đúng trải nghiệm người dùng. Bạn tối ưu query database xuống còn 2ms cũng được, nhưng nếu Garbage Collector quyết định chạy một lần Full GC Stop-The-World (STW) dài 800ms thì:

  • Hàng ngàn request tới trong khoảng đó bị dồn ứ.
  • Pause dài cỡ vài giây, hoặc nhiều pause liền nhau, có thể làm lỡ vài lần Liveness Probe liên tiếp, Kubernetes tưởng pod chết và restart nó.
  • Kết nối TCP timeout hàng loạt, kéo theo lỗi dây chuyền (cascading failure).

Trước Java 21, G1 GC đã kiểm soát pause time theo mục tiêu (-XX:MaxGCPauseMillis=200) khá tốt. Nhưng G1 di dời (evacuate) object trong lúc dừng ứng dụng, nên với heap lớn và nhiều object còn sống, pause vẫn thường vào khoảng vài chục tới vài trăm mili-giây. Sơ đồ dưới chỉ để hình dung thứ bậc, con số thực tế tuỳ heap và tải.


2. ZGC là gì, Generational ZGC (JEP 439) có gì mới?

ZGC (Z Garbage Collector) là bộ thu gom rác thế hệ mới, scale tốt và độ trễ thấp (scalable low-latency GC). Nó được thiết kế với mấy mục tiêu:

  1. Pause tối đa (Max Pause Time) không quá 1 mili-giây. (Mục tiêu ban đầu ở JEP 333 là 10ms; từ JDK 16, khi việc quét thread stack chuyển sang chạy concurrent (JEP 376), pause xuống dưới 1ms.)
  2. Pause KHÔNG tăng theo kích thước Heap: heap 16GB hay 16TB thì pause vẫn dưới 1ms!
  3. Chạy được với heap từ 8MB tới 16TB.

Cần hiểu đúng chữ "pause" ở đây: đó là các pha STW của GC. Nếu ứng dụng cấp phát nhanh hơn tốc độ ZGC dọn, thread vẫn có thể bị chặn vì Allocation Stall, và lúc đó độ trễ không còn dưới 1ms nữa. Nên ZGC vẫn cần heap đủ rộng so với tốc độ cấp phát.

2.1. ZGC bản đầu (Single-Generation) còn thiếu gì?

Ở bản đầu của ZGC (Java 11 - 17), cả heap được coi là một thế hệ duy nhất (Single-generation). Tức là chu kỳ GC nào cũng phải quét và xử lý cả object mới sinh lẫn object sống lâu.

Trong khi đó, giả thuyết Weak Generational Hypothesis nói rằng: phần lớn object chết ngay sau khi vừa được tạo (Young Objects). Không chia thế hệ thì ZGC tốn rất nhiều CPU để theo dõi đám object sống dai, dễ dính Allocation Stall (cấp phát object mới nhanh hơn tốc độ GC dọn kịp).

2.2. Generational ZGC xuất hiện (Java 21+)

Java 21 đưa Generational ZGC (JEP 439) thành tính năng chính thức:

  • Heap chia thành Young Generation và Old Generation.
  • GC chạy thường xuyên trên Young Generation để dọn thật nhanh mấy object ngắn hạn (DTO, biến local của request...).
  • Old Generation thì quét thưa hơn nhiều, đỡ được kha khá CPU overhead.

3. Vì sao độ trễ dưới 1ms: Colored Pointers & Load Barriers

ZGC làm sao di chuyển (relocate) object trong bộ nhớ trong khi các thread ứng dụng (Java Threads) vẫn đang đọc ghi chính object đó, mà không cần dừng hệ thống?

3.1. Colored Pointers (con trỏ màu)

Trên hệ điều hành 64-bit, ZGC không dùng cả 64 bit để trỏ địa chỉ bộ nhớ, mà "mượn" vài bit để lưu metadata của con trỏ. Hình dưới là bố cục của ZGC bản đầu (non-generational), dễ hình dung nhất:

+-------------------+-------------+-----------------------------------------------+
| 16 bits unused    | 4 color bits| 44 bits memory address (max 16TB RAM)         |
+-------------------+-------------+-----------------------------------------------+
                          |
                          +--> Finalizable (1 bit)
                          +--> Remapped (1 bit)
                          +--> Marked1 (1 bit)
                          +--> Marked0 (1 bit)

Nhờ mấy bit màu này, ZGC biết ngay trạng thái của object (đã được đánh dấu là còn sống chưa, đã bị dời sang vùng nhớ mới chưa) chỉ bằng cách nhìn con trỏ, không cần đọc header của object trong RAM.

Generational ZGC giữ ý tưởng đó nhưng đổi cách làm khá nhiều (JEP 439):

  • Metadata chuyển xuống các bit thấp của con trỏ, địa chỉ nằm ở các bit cao. Bản cũ phải map cùng một vùng nhớ vật lý vào nhiều địa chỉ ảo (multi-mapping) để con trỏ có màu vẫn dùng được trực tiếp; bản mới bỏ multi-mapping, barrier tự gỡ màu bằng lệnh máy.
  • Con trỏ chỉ "có màu" khi nằm trong heap. Khi được nạp lên thanh ghi hay stack của thread, nó là con trỏ không màu (colorless), dùng như địa chỉ bình thường.
  • Có thêm bit metadata riêng cho từng thế hệ (đánh dấu và di dời của young và old tách nhau, để hai thế hệ được dọn độc lập), cộng với bit cho store barrier.

3.2. Load Barriers (chốt chặn khi đọc dữ liệu)

Khi thread ứng dụng chạy lệnh truy cập field:

Order order = customer.getOrder();

JIT compiler tự chèn một đoạn mã máy rất nhẹ gọi là Load Barrier:

  1. Thread kiểm tra các bit màu của con trỏ customer.getOrder().
  2. Con trỏ "tốt" (đã trỏ đúng địa chỉ hợp lệ mới nhất): đọc luôn, fast path chỉ là vài lệnh máy (một phép kiểm tra và một nhánh rẽ).
  3. Con trỏ "chưa cập nhật" (object đang bị ZGC di dời): thread ứng dụng tự cập nhật con trỏ sang địa chỉ mới (Self-healing pointer) rồi mới đọc.

Nhờ vậy, ZGC di dời được hàng triệu object song song với thread ứng dụng, chỉ còn vài pha STW rất ngắn để đồng bộ đầu/cuối mỗi chu kỳ (không phụ thuộc kích thước heap).

Generational ZGC còn thêm Store Barrier khi ghi một tham chiếu vào field. Khi một object old được gán tham chiếu tới object young, GC phải biết để lúc dọn young generation không lỡ tay coi object young đó là rác. Store barrier ghi lại những tham chiếu xuyên thế hệ này (tương tự remembered set của G1), nhờ vậy dọn young không phải quét cả old.


4. Bật Generational ZGC trên Production

Từ Java 21, Generational ZGC đã dùng được cho Production. Trên Java 21 cần thêm 2 flag vào lệnh khởi động JVM:

java -XX:+UseZGC -XX:+ZGenerational -Xms16g -Xmx16g -jar my-service.jar

(Từ JDK 23 (JEP 474), -XX:+UseZGC mặc định là bản generational và -XX:+ZGenerational bị deprecated. Từ JDK 24 (JEP 490), bản non-generational bị gỡ hẳn, flag ZGenerational thành obsolete và JVM chỉ in cảnh báo. Trên Java 25 LTS, chỉ cần -XX:+UseZGC.)

Mấy thông số nên chỉnh (Tuning Tips):

  1. Cố định Heap Size: luôn để -Xms bằng -Xmx để JVM khỏi co giãn heap lúc runtime. Có thể thêm -XX:+AlwaysPreTouch để chạm trước toàn bộ trang nhớ lúc khởi động (khởi động chậm hơn, nhưng tránh độ trễ do page fault lúc đang chạy).
  2. Số GC Worker Threads:
    -XX:ConcGCThreads=4
    Mặc định ZGC tự tính dựa trên số lõi CPU. Máy ít CPU (dưới 4 cores) thì nên cân nhắc, vì ZGC chạy concurrent nên sẽ giành CPU với thread ứng dụng.
  3. Bật GC log cho chuẩn:
    -Xlog:gc*,gc+phases=debug:file=/var/log/jvm/gc.log:time,uptime,pid:filecount=5,filesize=100M

5. So sánh G1 GC và Generational ZGC

Tiêu chíG1 GC (Mặc định từ Java 9+)Generational ZGC (Java 21+)
Mục tiêu chínhCân bằng giữa Throughput và độ trễĐộ trễ cực thấp (Ultra-Low Latency)
Max Pause Time50ms – 250msDưới 1ms (< 1000 µs)
Ảnh hưởng của Heap SizePause tăng theo lượng object còn sống phải di dờiHeap 16GB hay 16TB pause như nhau
CPU OverheadThấpCao hơn (barrier + GC chạy song song, cần CPU dư)
Throughput tối đaRất caoThường thấp hơn G1 một chút, tuỳ workload
Hợp nhất vớiBatch processing, ứng dụng chịu được trễ ~100msFintech, Banking, High-QPS Microservices, Gaming

6. Lời khuyên về kiến trúc

Nếu backend của bạn đang có một trong mấy dấu hiệu sau:

  • P99 latency cao bất thường mà không đổ cho database query hay network call được.
  • Cảnh báo timeout từ Kubernetes liveness probe cứ vài chục phút lại hiện một lần.
  • Ứng dụng chạy trên máy RAM lớn (từ 16GB trở lên).

Thì nên thử lên Java 21 LTS (hoặc Java 25 LTS) và bật Generational ZGC. Không phải sửa dòng code nghiệp vụ nào, chỉ đổi flag JVM. Nhưng nhớ đo trước và sau: so P99 latency, CPU usage và throughput trên tải thật (hoặc tải giả lập sát thật), vì ZGC đổi một phần CPU lấy độ trễ thấp. Với job batch chỉ quan tâm tổng thời gian chạy, G1 hay Parallel GC vẫn thường là lựa chọn tốt hơn. Trước khi đổi GC, cũng nên xem GC log để chắc độ trễ cao thật sự do GC chứ không phải do thứ khác.


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