TungDaDev's Blog

Generational ZGC trong Java 21+: Chấm dứt ác mộng Stop-The-World (STW) cho Heap hàng Terabyte

Photo 1562774053 701939374585?auto=format&fit=crop&w=1600&q=80
Published on
/8 mins read/

Một trong những nỗi sợ lớn nhất của kỹ sư backend Java khi scale hệ thống lớn là GC Pause: toàn bộ luồng ứng dụng bị đóng băng (Stop-The-World), P99 latency vọt lên trời và health check báo timeout. Generational ZGC ra đời để biến nỗi sợ này thành dĩ vãng.

1. Nỗi đau mang tên Garbage Collection Pauses

Trong bất kỳ hệ thống phân tán chịu tải cao nào, P99 và P99.9 latency mới là thước đo phản ánh trải nghiệm người dùng chân thực nhất. Bạn có thể tối ưu code query database xuống 2ms, nhưng nếu Garbage Collector quyết định thực hiện một đợt Full GC Stop-The-World (STW) kéo dài 800ms:

  • Hàng ngàn request đến trong khoảng thời gian đó bị dồn ứ.
  • Load Balancer / Kubernetes tưởng pod đã chết vì lỡ mất Liveness Probe.
  • Các kết nối TCP timeout hàng loạt, tạo ra hiệu ứng tuyết lở (cascading failure).

Trước Java 21, dù G1 GC đã làm rất tốt việc kiểm soát pause time theo mục tiêu (-XX:MaxGCPauseMillis=200), nhưng với các heap cỡ lớn (từ 32GB đến hàng TB), việc dọn dẹp và di dời (relocation) object vẫn gây ra những đợt pause từ vài chục đến hàng trăm mili-giây.


2. ZGC là gì và Generational ZGC (JEP 439) có gì đột phá?

ZGC (Z Garbage Collector) là bộ thu gom rác thế hệ mới có thể mở rộng (scalable low-latency GC), được thiết kế với các mục tiêu:

  1. Thời gian tạm dừng tối đa (Max Pause Time) không vượt quá 1 mili-giây.
  2. Thời gian dừng KHÔNG tăng theo kích thước Heap: Heap 16GB hay 16TB thì pause time vẫn dưới 1ms!
  3. Xử lý được các heap từ 8MB đến 16TB.

2.1. Tại sao ZGC ban đầu (Single-Generation) chưa hoàn hảo?

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

Trong khi đó, định luật Weak Generational Hypothesis chỉ ra rằng: Hầu hết các object chết ngay sau khi vừa được sinh ra (Young Objects). Việc không chia thế hệ khiến ZGC tốn rất nhiều CPU để theo dõi những object sống dai, dễ dẫn đến hiện tượng Allocation Stall (tốc độ cấp phát object mới nhanh hơn tốc độ GC dọn dẹp kịp).

2.2. Sự xuất hiện của Generational ZGC (Java 21+)

Java 21 giới thiệu chính thức Generational ZGC (JEP 439):

  • Chia heap thành Young Generation và Old Generation.
  • GC chạy thường xuyên trên Young Generation để dọn dẹp thần tốc những object ngắn hạn (như DTO, local variable của request).
  • Old Generation được quét với tần suất thưa hơn nhiều, tiết kiệm đáng kể CPU overhead.

3. Bí mật đằng sau độ trễ dưới 1ms: Colored Pointers & Load Barriers

Làm thế nào ZGC có thể di chuyển (relocate) các object trong bộ nhớ trong khi các luồng ứng dụng (Java Threads) vẫn đang đọc và ghi vào 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 sử dụng toàn bộ 64 bit để trỏ địa chỉ bộ nhớ, mà "mượn" một số bit cao để lưu trạng thái metadata của con trỏ:

+-------------------+-------------+-----------------------------------------------+
| 16 bits không dùng| 4 bits màu  | 44 bits địa chỉ bộ nhớ thực (Tối đa 16TB RAM) |
+-------------------+-------------+-----------------------------------------------+
                          |
                          +--> Finalizable (1 bit)
                          +--> Remapped (1 bit)
                          +--> Marked1 (1 bit)
                          +--> Marked0 (1 bit)

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

3.2. Load Barriers (Rào cản khi đọc dữ liệu)

Khi luồng ứng dụng chạy câu lệnh truy cập trường dữ liệu:

Order order = customer.getOrder();

JIT compiler sẽ tự động chèn một đoạn mã máy siêu nhẹ gọi là Load Barrier:

  1. Luồng kiểm tra các bit màu của con trỏ customer.getOrder().
  2. Nếu con trỏ là "tốt" (đã trỏ vào địa chỉ hợp lệ mới nhất): Cho phép đọc ngay lập tức (chỉ tốn khoảng 1-2 chu kỳ CPU).
  3. Nếu con trỏ "chưa được cập nhật" (object đang trong quá trình bị ZGC di dời): Luồng ứng dụng sẽ tự động cập nhật lại con trỏ đó sang địa chỉ mới (Self-healing pointer) rồi mới đọc.

Nhờ cơ chế này, ZGC có thể di dời hàng triệu object đồng thời với luồng ứng dụng mà hoàn toàn không cần Stop-The-World!


4. Hướng dẫn bật Generational ZGC trong Production

Từ Java 21 trở lên, Generational ZGC đã sẵn sàng cho Production. Bạn chỉ cần thêm 2 flag đơn giản vào lệnh khởi động JVM:

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

(Lưu ý: Dự kiến trong các phiên bản Java tương lai, -XX:+ZGenerational sẽ trở thành mặc định khi bạn bật -XX:+UseZGC).

Các thông số tinh chỉnh quan trọng (Tuning Tips):

  1. Cố định Heap Size: Luôn set -Xms bằng -Xmx để tránh việc JVM phải co giãn heap tại runtime, giúp ZGC quản lý virtual memory mượt mà nhất.
  2. Số lượng GC Worker Threads:
    -XX:ConcGCThreads=4
    Mặc định ZGC sẽ tự tính toán dựa trên số lõi CPU. Nếu máy chủ có ít CPU (dưới 4 cores), bạn cần cân nhắc vì ZGC chạy concurrent nên sẽ chia sẻ CPU với luồng ứng dụng.
  3. Bật Logging GC chuẩn hóa:
    -Xlog:gc*,gc+phases=debug:file=/var/log/jvm/gc.log:time,uptime,pid:filecount=5,filesize=100M

5. Bảng so sánh toàn diện: G1 GC vs 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ễ siêu thấp (Ultra-Low Latency)
Max Pause Time50ms – 250msDưới 1ms (< 1000 µs)
Tác động của Heap SizeHeap càng to (64GB+), Pause càng dàiHeap 16GB hay 16TB pause như nhau
Chi phí CPU OverheadThấp (1% - 3% CPU cho GC)Vừa phải (Load barrier tốn ~2% - 5% CPU)
Throughput đỉnh caoRất caoThấp hơn G1 khoảng 2% - 5%
Phù 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 kiến trúc

Nếu hệ thống backend của bạn đang gặp phải một trong các dấu hiệu sau:

  • P99 latency cao bất thường và không thể giải thích bằng database query hay network call.
  • Các cảnh báo timeout từ Kubernetes liveness probe xuất hiện định kỳ mỗi vài chục phút.
  • Ứng dụng chạy trên các máy chủ có RAM lớn (từ 16GB trở lên).

Đừng ngần ngại nâng cấp lên Java 21 LTS và kích hoạt Generational ZGC. Đây là một trong những cải tiến giá trị nhất của hệ sinh thái Java trong 5 năm qua, mang lại bước nhảy vọt về độ ổn định dịch vụ mà không cần sửa đổi bất kỳ dòng code nghiệp vụ nà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 😎 👍🏻 🚀 🔥.