GC log là nguồn thông tin rẻ nhất về sức khoẻ một ứng dụng Java: bật gần như miễn phí, chạy được trên máy chủ thật, và trả lời được những câu mà biểu đồ CPU không trả lời nổi.

Vấn đề là nó trông đáng sợ. Bài này gỡ từng phần.

Bật cho đúng

# hằng ngày, chi phí gần bằng không, để luôn trong sản xuất
-Xlog:gc

# khi đang điều tra
-Xlog:gc*,gc+heap=debug

# ghi ra tệp, xoay vòng, giữ 5 tệp mỗi tệp 20MB
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20M

Tôi khuyên bật -Xlog:gc thường trực trên sản xuất. Nó ghi vài dòng mỗi giây, và khi sự cố xảy ra lúc 2 giờ sáng, có log là khác biệt giữa "biết ngay" và "phải tái hiện".

Lưu ý cho ai đọc tài liệu cũ: -XX:+PrintGCDetails, -XX:+PrintGCDateStamps, -Xloggc: đều đã bị bỏ từ Java 9. Chúng vẫn chạy nhưng in cảnh báo, và một ngày nào đó sẽ biến mất.

Giải phẫu một dòng

[0.345s][info][gc] GC(46) Pause Young (Normal) (G1 Evacuation Pause) 173M->162M(256M) 2.679ms

Đọc từ trái sang:

[0.345s] — thời điểm tính từ lúc JVM khởi động.

GC(46) — số thứ tự chu kỳ. Cùng một số có thể xuất hiện ở nhiều dòng, vì một chu kỳ đồng thời có nhiều giai đoạn.

Pause — chữ quan trọng nhất. Có Pause nghĩa là ứng dụng đứng im. Dòng không có chữ đó (Concurrent Mark Cycle) là GC làm việc song song, ứng dụng vẫn chạy.

Young / Mixed / Full — dọn vùng trẻ, dọn trẻ kèm một ít vùng già, hay dọn toàn bộ.

173M->162M(256M) — trước GC dùng 173MB, sau còn 162MB, tổng heap 256MB. Cặp số này là thứ đáng nhìn nhất trong cả dòng.

2.679ms — thời gian.

Ba con số cần nhìn trước tiên

Một: tỷ lệ thời gian dừng

Cộng mọi dòng có chữ Pause, chia cho thời gian chạy.

Và đây là chỗ tôi tự mắc lỗi khi làm bài này. Lần đầu tôi cộng mọi con số có đuôi ms trong log, và nhận kết quả 104,8% — GC chiếm hơn 100% thời gian, điều vô lý.

Nguyên nhân: tôi cộng cả các pha đồng thời, vốn chạy song song với ứng dụng chứ không dừng nó. Tách ra thì con số hợp lý ngay:

  chạy 0,27s | DỪNG 118,4ms (44,5%) | pha đồng thời 134,1ms

Bài học: chỉ cộng dòng có chữ Pause. Pha đồng thời tốn CPU chứ không tốn thời gian của ứng dụng.

Ngưỡng tôi dùng: dưới 5% là khoẻ, 5–10% đáng xem, trên 10% là có vấn đề cần xử lý.

Hai: vùng sống sau mỗi Full GC

Đây là con số quyết định để phân biệt "heap hơi chật" với "có rò rỉ".

Tôi chạy ba kịch bản và trích số sau mỗi Full GC:

  khoẻ mạnh   :  92  94  94                  <- phẳng
  heap chật   : 140 147 150 151 152          <- nhích dần rồi ổn định
  rò rỉ       : 237 249 252 252 252 -> OutOfMemoryError

Full GC dọn được mọi thứ có thể dọn. Nên con số sau nó chính là vùng sống thật.

Phẳng nghĩa là ứng dụng khoẻ. Tăng đều đặn và không bao giờ quay lại nghĩa là có thứ gì đó tích tụ — và đó là định nghĩa của rò rỉ bộ nhớ.

Dòng thứ ba cho thấy hình dạng cuối cùng: vùng sống chạm trần heap, mỗi Full GC dọn được gần như không gì, rồi OutOfMemoryError. Trước lúc đó, ứng dụng đã chậm kinh khủng vì GC chạy liên tục mà không thu được gì — người dùng cảm nhận sự cố trước khi log có dòng lỗi.

Ba: tần suất Full GC

Full GC với G1 nghĩa là nó đã bó tay và phải dọn toàn bộ heap trong một lần dừng dài.

Vài lần lúc khởi động là bình thường. Lặp lại đều đặn khi đã chạy ổn định thì luôn là dấu hiệu xấu — hoặc heap quá nhỏ, hoặc rò rỉ, hoặc có ai đó gọi System.gc().

Chi tiết đáng biết: nếu log ghi Pause Full (System.gc()) thì thủ phạm là mã của bạn hoặc một thư viện. Chặn bằng -XX:+DisableExplicitGC.

Heap bao nhiêu là đủ

Câu hỏi này trả lời được bằng đúng một bảng. Cùng tải, chỉ đổi -Xmx:

  heap     chạy      dừng     GC %   Young   Full   Evacuation Failure
  256m    0,29s   139,6ms    47,7%      36      3      3
  512m    0,22s    59,9ms    27,3%      19      0      0
  1g      0,20s    51,8ms    25,7%      14      0      0
  2g      0,20s    50,2ms    25,7%      13      0      0

Có một vách ngăn rõ ràng ở 512m: Full GC và Evacuation Failure cùng về 0, tỷ lệ GC giảm gần một nửa.

Và có một điểm bão hoà: từ 512m lên 2g, tỷ lệ GC chỉ nhích từ 27,3% xuống 25,7%. Bốn lần bộ nhớ để đổi lấy 1,6 điểm phần trăm.

Đây là cách tìm kích thước heap đúng, và nó tốt hơn mọi công thức: tăng dần cho tới khi Full GC về 0, rồi dừng lại. Cấp thêm nữa chỉ tốn RAM, và như bài 82 đã đo, RAM đó còn kéo theo cấu trúc GC lớn hơn.

Dấu hiệu heap quá nhỏ

Ba thứ xuất hiện cùng nhau, thấy trong cột của bảng trên:

Evacuation Failure — G1 muốn chép đối tượng sang vùng mới nhưng không còn vùng trống. Nó phải xử lý tình huống này bằng đường chậm, và đây là một trong những nguyên nhân gây dừng dài bất thường. Thấy chữ này là gần như chắc chắn cần thêm heap.

Full GC lặp lại khi đã chạy ổn định.

Khoảng cách giữa các lần Young GC ngắn dần — nghĩa là vùng trẻ bị co lại, thường vì vùng già đã chiếm gần hết heap.

Ngược lại, dấu hiệu heap quá lớn ít gặp hơn nhưng vẫn có: mỗi lần dừng rất lâu dù thưa, và RSS cao vô ích. Với G1 thì hiếm; với Parallel trên heap khổng lồ thì có thật.

Xem sâu hơn khi cần

-Xlog:gc+heap=debug
  region size 1024K, 23 young (23552K), 0 survivors (0K)
  Eden regions: 23->0(7)
  Survivor regions: 0->3(3)
  Old regions: 2->21

Ba dòng này cho thấy dòng chảy của đối tượng: Eden bị dọn sạch (23 về 0), 3 vùng sống sót sang Survivor, và Old tăng từ 2 lên 21 vùng.

Con số cần theo dõi là Old regions. Nó tăng đều sau mỗi chu kỳ nghĩa là đối tượng đang được đẩy lên vùng già nhanh hơn tốc độ dọn — hoặc do rò rỉ, hoặc do vùng trẻ quá nhỏ khiến đối tượng bị thăng cấp sớm.

Chú ý dòng Eden regions: 23->0(7): số trong ngoặc là kích thước Eden cho chu kỳ sau. G1 vừa co Eden từ 23 vùng xuống 7 — nó đang cố đạt mục tiêu tạm dừng, đúng cơ chế ở bài 85.

Bốn câu hỏi để chẩn đoán nhanh

Khi ai đó đưa bạn một GC log và hỏi "có vấn đề gì không", tôi rà theo thứ tự này:

Tỷ lệ Pause trên tổng thời gian là bao nhiêu? Trên 10% thì có chuyện.

Vùng sống sau Full GC có tăng đều không? Có thì đi tìm rò rỉ, đừng tăng heap — tăng heap chỉ dời sự cố sang tuần sau.

Evacuation Failure hay Full lặp lại không? Có thì thêm heap.

Lần dừng lâu nhất là bao nhiêu, và có chấp nhận được không? Nếu không, đó là lúc đọc lại bài 86 về ZGC và Shenandoah.

Bốn câu đó phân loại được gần hết các sự cố GC tôi từng gặp, và không câu nào cần công cụ ngoài grep.

Thử ba mươi giây

Lấy GC log của ứng dụng bạn, rồi chạy:

grep "Pause Full" gc.log | grep -oE '[0-9]+M->[0-9]+M'

Nhìn cột số bên phải mũi tên qua các dòng. Phẳng là khoẻ; tăng đều là rò rỉ.

Ba mươi giây, một lệnh, và nó trả lời câu hỏi mà rất nhiều đội phải chụp heap mới dám kết luận.

Ngày mai: tìm rò rỉ bộ nhớ cho ra nhẽ — chụp heap, đọc nó, và lần ngược chuỗi tham chiếu đang giữ đối tượng lại.