Java Internals & Concurrency/Chọn GC collector theo SLA latency và đọc GC log xác nhận
59/75
Bài 59 / 75~11 phútJVM InternalsMiễn phí lượt xem

Chọn GC collector theo SLA latency và đọc GC log xác nhận

Bốn collector, một câu hỏi: SLA p99 bao nhiêu và heap bao lớn? Flowchart chọn collector theo latency + throughput, và cách đọc -Xlog:gc* để xác nhận lựa chọn.

TL;DR: Chọn GC collector không phải chọn "cái tốt nhất" mà là khớp collector với SLA latency + cỡ heap. Batch/offline không có user chờ → Parallel GC (throughput tối đa). Web/microservice pause p99 vài chục tới vài trăm ms chấp nhận được → G1 (default Java 9+, ít phải tune). SLA p99 dưới 10ms hoặc heap vượt 32GB → ZGC / Shenandoah (concurrent compaction, đổi ~10-15% throughput lấy pause dưới 10ms). Sau khi chọn, xác nhận bằng GC log -Xlog:gc*=info: đọc pause type (young/full), reclaim ratio, và pause duration để biết collector có thực sự đạt SLA không. -Xlog:gc* là bằng chứng; đừng tune mù không có nó.

Bạn deploy một service mới, chọn collector theo... mặc định. App chạy ổn tới ngày traffic tăng, p99 latency (ngưỡng mà 99% request nằm dưới) thỉnh thoảng vọt lên 400ms và alert nổ. Câu hỏi không phải "collector nào tốt nhất" — mà collector nào khớp với SLA của service này, và làm sao xác nhận nó đang đạt SLA đó bằng số liệu chứ không phải cảm giác.

Bài 06 cho biết mỗi collector phối ba thuật toán nền khác nhau. Học xong bài này, bạn chọn được collector khớp SLA của service và đọc GC log để xác nhận lựa chọn bằng số liệu.

1. Bốn collector, một trục quyết định

Bài 06 liệt kê collector theo tên. Để chọn, xếp chúng theo hai trục thực dụng: pause profile (ảnh hưởng latency) và throughput (ảnh hưởng chi phí máy).

Hình dung như chọn phương tiện chở hàng theo tuyến (yêu cầu latency) và khối lượng (throughput cần):

Phương tiệnCollectorKhi nào
Xe tải đường dài — chở nhiều nhất, dừng lâu bốc dỡParallelLô hàng lớn, không ai đứng chờ (batch/ETL)
Xe khách tuyến cố định — cân bằng, phổ thôngG1Đa số web service, pause vài chục-vài trăm ms chấp nhận được
Xe cứu thương — luôn thông tuyến, dừng cực ngắn, tốn xăng hơnZGC / ShenandoahSLA latency gắt (dưới 10ms) hoặc heap rất lớn

Bảng kỹ thuật đầy đủ cho từng collector:

CollectorPause thực tếThroughputBật bằng
SerialDài, 1 threadThấp, overhead nhỏ nhất-XX:+UseSerialGC
Parallel100ms tới vài giâyCao nhất-XX:+UseParallelGC
G1 (default)50-200ms (soft target)Cao-XX:+UseG1GC
ZGCDưới ~1ms (sub-millisecond)Thấp hơn G1 ~10-15%-XX:+UseZGC
ShenandoahDưới ~10msThấp hơn G1 ~10-15%-XX:+UseShenandoahGC

Java 21 mặc định G1. -XX:MaxGCPauseMillis=200 là soft target mặc định của G1 — nó cố giữ pause dưới 200ms bằng heuristic chọn số region quét mỗi cycle, không phải cam kết tuyệt đối.

💡 Cách nhớ

Ba câu hỏi theo thứ tự: (1) Có user chờ không? Không → Parallel. (2) Pause vài chục-vài trăm ms có sao không? Không sao → G1. (3) Cần dưới 10ms hoặc heap vượt 32GB? → ZGC/Shenandoah.

2. Nên chọn collector nào cho SLA của service?

💡 Tình huống — tự chọn trước

Service đấu giá real-time: SLA p99 latency 5ms, heap 12GB, chạy Java 21. Trước khi xem flowchart bên dưới, tự viết ra collector bạn chọn và lý do (dựa trên hai trục ở §1). Chốt xong mới đọc tiếp.

Với p99 = 5ms, mọi collector stop-the-world (Parallel, và cả G1 với pause 50-200ms) đều loại ngay — một pause 50ms đã xài hết 10 lần budget. Chỉ ZGC hoặc Shenandoah (pause dưới 10ms) đạt. Heap 12GB không bắt buộc ZGC vì cỡ này, nhưng ràng buộc latency thì có. Đáp án: ZGC (production từ JDK 15; trên Java 21 bật chế độ generational bằng -XX:+ZGenerational — generational thành mặc định từ JDK 23, xem bài 12b).

Đây là toàn bộ cây quyết định:

flowchart TD
    A["Workload + SLA"] --> B{"Co user cho request?"}
    B -->|"Khong (batch, ETL)"| C["Parallel GC"]
    B -->|"Co"| D{"SLA p99 latency?"}
    D -->|"Vai chuc - vai tram ms OK"| E["G1 (default)"]
    D -->|"Duoi 10ms bat buoc"| F["ZGC / Shenandoah"]
    E --> G{"Heap vuot 32GB?"}
    G -->|"Co"| F
    G -->|"Khong"| H["G1 giu nguyen"]

Nhánh cuối: ngay cả khi SLA rộng, heap vượt 32GB vẫn nên cân nhắc ZGC. Lý do chính là Full GC của G1 (fallback compacting toàn heap) có pause leo theo cỡ heap — càng nhiều region càng lâu — trong khi ZGC giữ pause phẳng bất kể heap. Pause youngmixed của G1 thì đều được adaptive-sizing ghìm quanh MaxGCPauseMillis bất kể heap (điểm §1); chỉ Full GC mới thực sự scale theo cỡ heap. Một điểm phụ: qua mốc ~32GB thì mọi collector đều mất compressed oops (reference thành 64-bit), nên việc ZGC vốn dùng 64-bit colored pointer (không bao giờ có compressed oops) không còn là bất lợi footprint riêng ở cỡ heap này.

3. Đọc GC log — xác nhận bằng số liệu

Chọn xong phải verify. Bật unified GC log (định dạng chuẩn từ Java 9):

java -Xlog:gc*=info:file=gc.log:time,uptime,level,tags MyApp

Một dòng pause young của G1 trông như sau:

[2026-04-27T10:00:00.145+0700][1.256s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 192M->24M(256M) 22.345ms
💡 Đọc trước khi xem giải

Nhìn dòng log trên: đây là minor hay major GC? Nó thu hồi được bao nhiêu phần trăm heap đang dùng? Heap có đang chịu áp lực không? Viết ra câu trả lời của bạn trước khi đọc phần phân tích.

Bóc từng trường:

  • GC(0) — ID của cycle.
  • Pause Young (Normal)minor GC (chỉ quét young). Nếu là Pause Full thì mới là major/full GC.
  • 192M->24M(256M) — heap dùng trước → sau (tổng heap). Reclaim 168MB.
  • 22.345ms — thời gian pause.

Phân tích sức khoẻ:

  • Reclaim ratio = 168/192 ≈ 87% → phần lớn là rác, generational hypothesis (giả thuyết hầu hết object chết trẻ — bài 06) đang đúng.
  • Utilization sau GC = 24/256 ≈ 9% → heap dư thoải mái, chưa áp lực.
  • Pause 22ms → khoẻ với web service (SLA p99 50-200ms).

3.1. Pattern khoẻ vs pattern nguy

Một dòng đơn lẻ không đủ — nhìn xu hướng qua nhiều cycle:

# KHOE: heap-after on dinh, reclaim cao, pause on dinh
GC(1) 192M->24M(256M) 20ms
GC(2) 195M->26M(256M) 22ms
GC(3) 198M->25M(256M) 21ms

# NGUY: heap-after tang dan, reclaim giam, pause phinh -> leak
GC(1) 192M->180M(256M) 50ms
GC(2) 250M->240M(256M) 80ms
GC(3) 256M->255M(256M) 200ms (Pause Full)

Ở pattern nguy, after không giảm nghĩa là object già không chết — old gen phình dần, Full GC dày lên rồi tiến tới OutOfMemoryError. Đây là chữ ký memory leak đọc thẳng từ GC log.

4. KPI cần monitor

Không đọc log bằng mắt trong production — ship vào tool và set alert trên bốn KPI:

  • Pause p99 — cao nhất 99% events, phải khớp SLA. Vượt SLA = đổi collector hoặc tune.
  • Throughput % — phần trăm thời gian app chạy (ngoài GC). Tốt khi vượt 95%.
  • Full GC frequency — vượt 1 lần/giờ là red flag: heap quá nhỏ hoặc leak.
  • Allocation rate — byte alloc/giây; tăng đột biến = pressure tăng, nguồn của promote sớm (bài 06).

Tool đọc log: GCEasy (paste log → report), GCViewer (timeline desktop), hoặc JFR (event GC kèm cause + region count — xem bài 08).

5. Pitfall tổng hợp

Nhầm 1: Chọn ZGC cho heap nhỏ "cho hiện đại".

java -XX:+UseZGC -Xmx1g MyApp   # overhead ~10-15% khong bu lai duoc voi heap nho

✅ Heap dưới 2GB dùng Serial hoặc G1 — GC cycle vốn đã nhanh, overhead ZGC không đáng.

Nhầm 2: Dùng Parallel cho web service vì "throughput cao".

Parallel full GC 10GB heap -> pause vai giay -> user thay lag intermittent

✅ Web có user chờ → G1 (hoặc ZGC nếu p99 gắt). Parallel dành cho batch không ai chờ.

Nhầm 3: Tune hàng loạt flag mà không đọc log.

-XX:MaxGCPauseMillis=50 -XX:G1HeapRegionSize=8m -XX:...   # doan mo

✅ Đo trước bằng -Xlog:gc*, xác định bottleneck (pause? frequency? promote rate?), rồi tune một biến một.

6. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

Ghi chú: "Selecting a Collector" trong GC Tuning Guide là điểm khởi đầu chuẩn — đọc bảng đó trước khi quyết. -Xlog:gc* là công cụ quan trọng nhất: mọi quyết định tune phải bắt đầu từ log thực, không từ giả định.

7. Liên hệ các bài khác

8. Tóm tắt

  • Chọn collector = khớp SLA latency + cỡ heap: batch → Parallel; web pause vài chục-vài trăm ms → G1 (default); p99 dưới 10ms hoặc heap vượt 32GB → ZGC/Shenandoah.
  • -XX:MaxGCPauseMillis=200 là soft target mặc định của G1 — heuristic, không cam kết tuyệt đối.
  • ZGC/Shenandoah đổi ~10-15% throughput lấy pause dưới 10ms → chỉ chọn khi latency là ràng buộc thật; heap nhỏ dùng chúng là lãng phí.
  • Bật -Xlog:gc*=info:file=gc.log để verify: đọc pause type, reclaim ratio (before->after), pause duration.
  • Đọc xu hướng qua nhiều cycle: heap-after ổn định + reclaim cao + pause ổn định = khoẻ; heap-after tăng dần = leak.
  • Monitor 4 KPI: pause p99, throughput %, Full GC frequency (hơn 1 lần/giờ = red flag), allocation rate. Ship log vào GCEasy/GCViewer/JFR.
  • Tune từng biến một sau khi đọc log, không đoán mò — mọi quyết định đổi collector hay chỉnh flag phải bắt đầu từ log thực.

9. Tự kiểm tra

Tự kiểm tra
Q1
Service có SLA p99 = 8ms, heap 40GB, Java 21. Chọn collector nào và vì sao loại các collector khác?

ZGC (Shenandoah cũng dưới 10ms nhưng sát ngưỡng 8ms hơn). Loại Parallel ngay vì pause hàng trăm ms tới vài giây. Loại G1 vì pause thực tế 50-200ms — vượt xa 8ms; hơn nữa ở heap 40GB, nếu G1 phải Full GC (compacting toàn heap) thì pause đó leo cao theo cỡ heap.

ZGC đạt vì pause dưới ~1ms bất kể cỡ heap (concurrent compaction), và được thiết kế cho heap lớn. Đánh đổi phải chấp nhận: throughput thấp hơn G1 ~10-15%, nên cần cấp thêm CPU/instance để bù. Java 21 có generational ZGC nhưng phải bật bằng -XX:+ZGenerational (mặc định từ JDK 23) — generational cho throughput tốt hơn non-gen (xem bài 12b).

Q2
Vì sao không nên mặc định chọn ZGC cho mọi service "cho chắc pause thấp"?

ZGC không miễn phí: load/read barrier check trên mỗi lần đọc reference gây overhead throughput ~10-15%, cộng thêm ~10-20% memory overhead và CPU cho GC thread chạy concurrent. Với service mà SLA latency rộng (web thường, batch), throughput drop đó nghĩa là cần thêm ~15% máy để giữ cùng công suất — chi phí thật.

Heap nhỏ (dưới 2GB) càng vô lý: GC cycle của G1 vốn đã nhanh vì heap bé, overhead absolute của ZGC không bù lại. Nguyên tắc: chỉ trả giá throughput khi latency thực sự là ràng buộc.

Q3
Dòng log 256M->255M(256M) 200ms (Pause Full) lặp lại nhiều lần nói lên điều gì?

Đây là chữ ký nguy hiểm. 256M->255M nghĩa là Full GC (quét toàn heap) chỉ thu hồi được 1MB — gần như không có gì là rác. Heap gần chạm trần (256M/256M) và pause 200ms dài.

Lặp lại nghĩa là object già không chết: old gen đầy, mỗi Full GC dọn được rất ít, app tiến dần tới OutOfMemoryError. Nguyên nhân thường là memory leak (giữ reference ngoài ý muốn) hoặc heap sizing quá nhỏ cho working set thật. Bước tiếp: heap dump để tìm object nào giữ reference tới rác.

Q4
Trong GC log, làm sao phân biệt minor GC với major/full GC, và vì sao phân biệt này quan trọng?

Nhìn nhãn pause: Pause Young là minor GC (chỉ quét young — Eden + Survivor); Pause Full (với G1 là Pause Full (G1 Compaction Pause)) là major/full GC (quét toàn heap).

Quan trọng vì hai loại có ý nghĩa sức khoẻ rất khác: minor GC thường xuyên và ngắn (vài ms tới vài chục ms) là bình thường — young được dọn liên tục. Full GC thì đắt (hàng trăm ms tới giây) và nên hiếm; nếu Full GC xuất hiện thường xuyên (hơn 1 lần/giờ) thì đó là red flag về leak hoặc heap sizing, cần điều tra ngay.

Q5
Service đặt SLA p99 = 50ms. GC log G1 hiện dòng Pause Young (Normal) 900M->120M(2G) 120.4ms lặp đều — collector này đã đạt SLA chưa? Đọc trường nào để kết luận, và bước tiếp là gì?

Chưa đạt. Trường quyết định là pause duration cuối dòng: 120.4ms vượt xa budget 50ms — mà đây mới là một pause young đơn lẻ, p99 thực tế còn cao hơn. Reclaim 900M->120M (≈87%) và util sau 120M/2G (≈6%) cho thấy heap khoẻ, nên nguyên nhân KHÔNG phải leak hay heap nhỏ — mà là G1 không giữ nổi pause dưới SLA ở cỡ heap này.

Bước tiếp có hai hướng: (1) thử ghìm bằng -XX:MaxGCPauseMillis=50 rồi đo lại — nhưng target quá thấp làm GC chạy dày, throughput sụt; (2) nếu SLA là ràng buộc cứng, đổi sang ZGC/Shenandoah (pause dưới 10ms). Quyết định nào cũng phải xác nhận lại bằng GC log, không đoán.

Q6
Bạn được giao "giảm GC pause" cho một service nhưng chưa có GC log. Bước đầu tiên là gì và vì sao không tune ngay?

Bước đầu tiên: bật -Xlog:gc*=info:file=gc.log và thu thập log dưới traffic thật trước khi đụng bất cứ flag nào. Tune ngay là đoán mò — bạn chưa biết pause đến từ minor GC (young quá nhỏ? allocation rate cao?) hay Full GC (leak? old sizing?), mỗi nguyên nhân có cách sửa khác nhau.

Từ log, xác định: pause loại nào chiếm p99, tần suất, reclaim ratio, allocation rate. Chỉ khi biết bottleneck mới tune một biến một và đo lại. Không có log thì mọi flag đổi vào là mê tín, và có thể làm tệ hơn (ví dụ giảm MaxGCPauseMillis làm GC chạy dày hơn, throughput sụt).

Bài tiếp theo: CLI tools — jps, jstack, jmap, jstat và jcmd

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

CLI tools chẩn đoán JVM — jps, jstack, jmap, jstat, jcmd