Bộ thu gom rác là thứ khiến người ta viết Java mà không phải nghĩ về free(). Nó cũng là thứ bị đổ lỗi nhiều nhất mỗi khi một hệ thống Java chạy giật.
Bài này đo, thay vì kể.
Giả thuyết thế hệ, bằng số
Toàn bộ thiết kế GC hiện đại dựa trên một quan sát: phần lớn đối tượng chết rất trẻ. Biến cục bộ, đối tượng trung gian trong stream, DTO của một request — chúng sống vài mili giây.
Tôi cấp đúng 400 MB theo hai kiểu và đọc log GC:
CHẾT TRẺ (cấp rồi bỏ ngay):
78M->1M(131M) 0.696ms <- dọn được 99%
8 lần Young GC | tổng dừng 4,2 ms | lâu nhất 0,70 ms | chạy hết 39 ms
SỐNG LÂU (giữ 400.000 mảng):
118M->95M(298M) 0.651ms <- chỉ dọn được 20%
12 lần Young GC | tổng dừng 44,7 ms | lâu nhất 10,97 ms | chạy hết 93 ms
Cùng khối lượng cấp phát, mười lần chênh lệch thời gian dừng.
Chú ý cặp số trong log: 78M->1M nghĩa là trước GC dùng 78MB, sau còn 1MB. Với đối tượng chết trẻ, GC gần như miễn phí — vì chi phí của thuật toán sao chép tỷ lệ với số đối tượng còn sống, không phải số đối tượng đã chết. Rác không tốn gì để dọn; thứ tốn kém là những đối tượng phải chép sang chỗ mới.
Đây là điều phản trực giác quan trọng nhất về GC: cấp phát nhiều không đắt, giữ lại nhiều mới đắt.
Hệ quả thực dụng: đừng ngại tạo đối tượng tạm. Ngại là những bộ nhớ đệm giữ mọi thứ mãi mãi, những static List lớn dần, những đối tượng sống sót qua nhiều chu kỳ.
Bốn bộ thu gom, cùng một tải
Tải mô phỏng ứng dụng web: một bộ nhớ đệm 200.000 mục cộng rác liên tục, heap 1GB.
bộ thu gom tổng chạy tổng dừng dừng lâu nhất số lần
Serial 818 ms 647,7 ms 43,69 ms 30
Parallel 260 ms 66,9 ms 11,11 ms 10
G1 271 ms 99,7 ms 20,77 ms 21
ZGC thế hệ 491 ms 0,168 ms 0,025 ms 17
Bốn dòng này gói gần hết những gì cần biết để chọn.
Serial — một luồng, dừng cả thế giới. Tệ nhất ở mọi cột trong phép đo này. Nó chỉ hợp với heap rất nhỏ và máy một nhân; JVM tự chọn nó khi thấy container quá nhỏ, và đó là bẫy — hãy kiểm.
Parallel — nhiều luồng dọn, vẫn dừng cả thế giới nhưng ngắn hơn nhiều. Thông lượng tốt nhất bảng: 260 ms, nhanh hơn cả G1. Nếu bạn chạy công việc theo lô mà không ai chờ phản hồi, đây vẫn là lựa chọn đúng năm 2026.
G1 — mặc định từ Java 9. Chậm hơn Parallel một chút về tổng thời gian, đổi lại số lần dừng nhiều hơn nhưng mỗi lần dự đoán được hơn. Nó là lựa chọn cân bằng, và là lý do nó làm mặc định.
ZGC thế hệ — con số làm tôi phải đo lại hai lần: tổng dừng 0,168 mili giây, lần dừng lâu nhất 0,025 mili giây. Hai mươi lăm micro giây.
Nhưng đọc cột đầu: 491 ms, chậm hơn Parallel gần gấp đôi. ZGC làm gần hết việc đồng thời với ứng dụng, nên nó ăn CPU và ăn bộ nhớ để đổi lấy việc gần như không bao giờ dừng.
Đó là đánh đổi trung tâm của cả bài: thông lượng hay độ trễ, chọn một.
Chọn cái nào
Ứng dụng web, API, thứ gì có người ngồi chờ → G1 (mặc định) là điểm khởi đầu tốt. Heap lớn hơn 32GB hoặc yêu cầu độ trễ ngặt thì chuyển sang ZGC.
Xử lý theo lô, ETL, tính toán → Parallel. Không ai quan tâm dừng 40ms nếu tổng thời gian ngắn hơn.
Container nhỏ, tiến trình sống ngắn → Serial thật sự hợp lý; ít chi phí quản lý nhất.
Và lời khuyên tôi muốn nhấn mạnh: đừng đổi bộ thu gom trước khi đo. Bảng trên là của tải này; tải của bạn có thể cho thứ tự khác.
MaxGCPauseMillis: không phải cái nút thần kỳ
G1 nhận một mục tiêu thời gian dừng và tự điều chỉnh để đạt được. Nghe như chỉ cần đặt số nhỏ là xong:
mục tiêu tổng chạy dừng lâu nhất số lần GC
MaxGCPauseMillis=200 274 ms 39,26 ms 21
MaxGCPauseMillis=50 247 ms 5,69 ms 17
MaxGCPauseMillis=10 460 ms 28,50 ms 32
Từ 200 xuống 50: tốt hơn ở mọi mặt — dừng lâu nhất giảm bảy lần, tổng thời gian còn nhanh hơn chút.
Từ 50 xuống 10: tệ hơn hẳn. Tổng thời gian tăng gần gấp đôi, số lần GC tăng, và lần dừng lâu nhất vẫn là 28,5 ms — không hề đạt mục tiêu 10 ms.
MaxGCPauseMillis là mục tiêu, không phải bảo đảm. Đặt quá thấp thì G1 co vùng trẻ lại để mỗi lần dọn ít việc hơn — nhưng vùng trẻ nhỏ nghĩa là GC chạy thường xuyên hơn, đối tượng bị đẩy lên vùng già sớm hơn, và cuối cùng bạn nhận nhiều lần dừng hơn và vẫn có những lần dừng dài.
Cách dùng đúng: đặt một con số phản ánh yêu cầu thật (50–200 ms cho phần lớn ứng dụng web), rồi đo. Nếu không đạt, vấn đề thường nằm ở heap quá nhỏ hoặc tốc độ cấp phát quá cao, không nằm ở tham số này.
Region: cách G1 chia heap
G1 không chia heap thành ba khối liền nhau như các bộ thu gom cũ. Nó cắt thành nhiều vùng nhỏ bằng nhau, và mỗi vùng tại một thời điểm đóng vai trò Eden, Survivor, hoặc Old.
heap 512m -> region 1 MB
heap 2g -> region 1 MB
heap 8g -> region 4 MB
JVM tự chọn kích thước sao cho có khoảng 2048 vùng.
Nhờ chia vùng, G1 làm được điều tên nó nói — Garbage First: nó thống kê vùng nào nhiều rác nhất và dọn những vùng đó trước, trong giới hạn thời gian cho phép. Đó là cách nó bám theo mục tiêu tạm dừng.
Đối tượng khổng lồ: cái bẫy của G1
Đối tượng lớn hơn nửa vùng không nằm gọn trong một vùng thường. G1 gọi chúng là humongous và xử lý theo cơ chế riêng: cấp một dãy vùng liền nhau, và trước đây còn không dọn được trong Young GC.
Tôi cấp 2000 mảng, chỉ đổi kích thước mỗi mảng, heap 512MB (vùng 1MB):
mảng 100.000 byte -> 5 lần GC
mảng 3.000.000 byte -> 37 lần GC, log có nhắc "Humongous"
Bảy lần nhiều hơn, cùng số lượng đối tượng.
Trong thực tế, đây là chuyện xảy ra với bộ đệm lớn, mảng byte đọc cả tệp, ảnh, hoặc StringBuilder phình to. Ba cách xử lý: chia nhỏ dữ liệu, tăng -XX:G1HeapRegionSize, hoặc dùng bộ nhớ ngoài heap cho những khối lớn cố định.
Dấu hiệu nhận biết trong log là chữ Humongous, và nó đáng tìm mỗi khi G1 chạy nhiều GC hơn bạn nghĩ.
Đọc log GC
java -Xlog:gc MyApp # gọn, đủ dùng hằng ngày
java -Xlog:gc*,gc+phases -Xlog:gc:gc.log MyApp # chi tiết, ghi ra tệp
Ba thứ cần nhìn:
Cặp số trước và sau. 118M->95M nghĩa là chỉ dọn được 23MB — vùng sống đang lớn. 78M->1M là khoẻ mạnh.
Tổng thời gian dừng chia cho thời gian chạy. Dưới 5% là ổn với phần lớn ứng dụng. Trên 10% là cần xem lại.
Chữ Full. 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 thì bình thường; lặp lại đều đặn là dấu hiệu heap quá nhỏ hoặc có rò rỉ.
Đáng nói: cờ -XX:+PrintGCDetails đã bị bỏ từ Java 9, thay bằng hệ thống -Xlog thống nhất. Rất nhiều hướng dẫn trên mạng vẫn dùng cờ cũ và bạn sẽ nhận cảnh báo.
Thử ba mươi giây
Chạy ứng dụng của bạn với -Xlog:gc trong vài phút tải bình thường, rồi:
grep -oE '[0-9.]+ms' gc.log | tr -d 'ms' | awk '{s+=$1} END{print s" ms tổng dừng"}'
Chia cho tổng thời gian chạy. Con số đó là phần thời gian ứng dụng của bạn đứng im, và phần lớn người tôi hỏi đều không biết nó là bao nhiêu trước khi đo.
Ngày mai đi sâu vào con số 0,025 mili giây ở trên: ZGC và Shenandoah — tạm dừng dưới một mili giây đánh đổi bằng cái gì, và khi nào thì đáng chuyển sang.