99 Ngày Java — Ngày 72: Garbage Collection
Ngày 71 ta đã vẽ bản đồ bộ nhớ JVM: stack cho frame, heap cho object, metaspace cho metadata của class. Hôm nay đi vào cỗ máy dọn dẹp heap: Garbage Collector. Bạn không gọi free(), nhưng vẫn cần hiểu GC quyết định thu hồi thứ gì, khi nào nó dừng ứng dụng, Java 21 có những collector nào, và đọc log GC thế nào để biết mình đang gặp vấn đề bộ nhớ hay chỉ đang thấy hành vi bình thường.

1. Reachability: GC dò từ gốc, không đếm tham chiếu
Một object đủ điều kiện thu hồi khi không còn đường đi nào từ tập GC root tới nó. GC root gồm biến local và tham số trên stack của các luồng đang chạy, static field của class đã nạp, tham chiếu do JNI giữ, và vài cấu trúc nội bộ của JVM. GC bắt đầu từ các root, đánh dấu mọi object với tới được, phần còn lại là rác.
import java.util.ArrayList;
import java.util.List;
public class Reachability {
// GC root: static field sống cùng class → mọi thứ nó trỏ tới đều "còn sống"
static final List<byte[]> KEEP = new ArrayList<>();
static void process() {
byte[] temp = new byte[1_000_000]; // reachable qua biến local trên stack
KEEP.add(new byte[1_000_000]); // reachable qua GC root tĩnh → không bao giờ được thu
} // ra khỏi process(): temp không còn root nào trỏ tới → đủ điều kiện thu hồi
public static void main(String[] args) {
for (int i = 0; i < 50; i++) {
process();
}
// Vòng tham chiếu KHÔNG cứu được object: GC dò từ root, không đếm tham chiếu
Node a = new Node(); Node b = new Node();
a.next = b; b.next = a;
a = null; b = null; // cả hai không còn root → đủ điều kiện thu hồi
System.out.println("KEEP giữ " + KEEP.size() + " MB không thể thu hồi");
}
static final class Node { Node next; }
}tempchỉ sống trong frame củaprocess(); frame biến mất thì đường đi từ root cũng biến mất. Bạn không cần gánnullcho biến local trước khi hàm kết thúc.KEEPlà static field: mọi mảng nó giữ đều reachable cho tới khi class bị unload, thường là suốt đời JVM. Đây là dạng rò rỉ bộ nhớ phổ biến nhất trong Java: object vẫn reachable ngoài ý muốn, GC không thể và không được phép thu nó.Vòng tham chiếu
a → b → akhông giữ được object nếu cả vòng đã mất kết nối với root. HotSpot không dùng reference counting nên không có vấn đề vòng tham chiếu như một số runtime khác.Thời điểm thu hồi không xác định. Object đủ điều kiện có thể nằm lại rất lâu nếu heap còn rộng; đừng viết logic dựa vào việc GC chạy ngay.
2. Thế hệ: minor GC rẻ, full GC đắt
Quan sát thực nghiệm gọi là giả thuyết thế hệ: phần lớn object chết rất trẻ, một số ít sống rất lâu. Các collector phổ biến chia heap thành young generation (eden và hai survivor space) và old generation. Object mới sinh ở eden; sống qua vài lần thu (tenuring threshold) thì được đưa lên old.
Minor GC chỉ dọn young. Vì đa số object ở đó đã chết, việc sao chép số ít còn sống sang survivor rẻ hơn nhiều so với quét cả heap. Tần suất cao, mỗi lần ngắn.
Major / full GC dọn old generation, hoặc toàn bộ heap. Đắt vì object sống dai nhiều và có thể phải nén (compaction) để chống phân mảnh. Full GC lặp lại liên tục là dấu hiệu heap quá nhỏ hoặc có rò rỉ.
Mọi collector đều có pha stop-the-world: mọi luồng ứng dụng dừng tại safepoint để GC làm việc an toàn. Khác biệt giữa các collector nằm ở việc bao nhiêu công việc được làm đồng thời với ứng dụng để rút ngắn pause.
Promotion quá sớm (survivor quá nhỏ) đẩy object chết trẻ lên old, khiến old đầy nhanh và full GC xuất hiện sớm. Đó là lý do kích thước young có ảnh hưởng lớn đến hành vi GC.
3. Collector trong Java 21: G1 mặc định, ZGC cho độ trễ thấp
# Chọn collector (Java 21)
java -XX:+UseG1GC App # mặc định trên máy server-class
java -XX:+UseZGC -XX:+ZGenerational App # ZGC thế hệ (JEP 439) — pause dưới mili-giây
java -XX:+UseParallelGC App # throughput tối đa, chấp nhận pause dài
java -XX:+UseSerialGC App # một luồng, heap nhỏ, container 1 CPU
# Ghi log GC ra file, kèm thời điểm và thời gian chạy
java -Xlog:gc*:file=gc.log:time,uptime App
# Mục tiêu pause cho G1 (gợi ý, không phải bảo đảm)
java -XX:MaxGCPauseMillis=100 AppSerial: một luồng, dừng toàn bộ. Hợp với heap nhỏ và container giới hạn một CPU; JVM tự chọn nó khi phát hiện máy không đủ CPU hoặc bộ nhớ để coi là server-class.
Parallel: nhiều luồng GC nhưng vẫn dừng toàn bộ ứng dụng. Cho throughput cao nhất khi bạn chấp nhận pause dài, ví dụ batch job chạy đêm.
G1 (Garbage-First): mặc định từ Java 9. Chia heap thành region, ưu tiên dọn region nhiều rác trước, đánh dấu đồng thời với ứng dụng, cố gắng giữ pause dưới
-XX:MaxGCPauseMillis(mặc định 200 ms). Lựa chọn cân bằng cho hầu hết dịch vụ web.ZGC: pause dưới một mili-giây bất kể heap lớn cỡ nào, nhờ làm gần như toàn bộ công việc đồng thời. Java 21 thêm Generational ZGC (JEP 439) bật bằng
-XX:+ZGenerational, giảm hẳn chi phí CPU so với ZGC một thế hệ. Đổi lại throughput thấp hơn G1 một chút và tốn thêm bộ nhớ. Chọn khi độ trễ đuôi (p99) quan trọng hơn tổng công việc.Ba mục tiêu throughput, độ trễ và dấu chân bộ nhớ không thể cùng tối đa. Trước khi đổi collector, hãy đo với tải thật; đổi flag theo bài viết trên mạng mà không đo là cách nhanh nhất để làm hệ thống chậm đi.
4. Đọc log GC bằng unified logging
Từ Java 9, mọi log của JVM đi qua -Xlog. Với -Xlog:gc bạn nhận một dòng cho mỗi sự kiện GC, đủ để trả lời hai câu hỏi thường gặp: heap đang được thu hồi hiệu quả không, và pause dài bao nhiêu. Chạy thử chương trình dưới với heap 64 MB:
import java.util.ArrayList;
import java.util.List;
public class GcDemo {
public static void main(String[] args) throws InterruptedException {
List<byte[]> longLived = new ArrayList<>();
for (int round = 1; round <= 200; round++) {
byte[] garbage = new byte[256 * 1024]; // chết trẻ: bị thu ở minor GC
if (round % 20 == 0) {
longLived.add(new byte[2 * 1024 * 1024]); // sống dai: rồi sẽ được đưa lên old gen
}
Thread.sleep(5);
}
System.out.println("giữ lại " + longLived.size() + " khối 2 MB");
}
}
// Chạy: java -Xmx64m -Xlog:gc GcDemo.java[0.006s][info][gc] Using G1
[0.318s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 24M->3M(64M) 2.114ms
[0.612s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 27M->5M(64M) 1.802ms
[1.240s][info][gc] GC(2) Pause Young (Concurrent Start) (G1 Humongous Allocation) 30M->9M(64M) 2.930ms
[1.241s][info][gc] GC(3) Concurrent Mark Cycle
[1.255s][info][gc] GC(3) Pause Remark 12M->12M(64M) 0.611ms
[1.259s][info][gc] GC(3) Pause Cleanup 12M->12M(64M) 0.087ms
[1.262s][info][gc] GC(3) Concurrent Mark Cycle 21.301ms
// GC(n): số thứ tự sự kiện · Pause = stop-the-world · 24M->3M(64M): heap trước->sau (tổng hiện tại)Mỗi dòng: thời điểm từ lúc JVM khởi động, số thứ tự
GC(n), loại sự kiện, nguyên nhân trong ngoặc,trước->sau(tổng)và thời gian pause.24M->3Mnghĩa là phần lớn young là rác, đúng như kỳ vọng.Nhìn đường đáy: giá trị heap sau mỗi lần thu. Nếu đáy cứ cao dần qua nhiều chu kỳ dù tải không đổi, có thứ gì đó đang tích lũy: nghi rò rỉ, chuyển sang heap dump ở Ngày 75.
Concurrent Mark Cyclelà G1 đánh dấu old generation song song với ứng dụng; chỉPause RemarkvàPause Cleanuplà dừng ứng dụng, và rất ngắn. DòngPause Fullxuất hiện nhiều mới là điều đáng lo.Trên production, ghi ra file kèm
time,uptimevà xoay vòng bằngfilecount/filesize. Log GC rất rẻ, không có lý do để tắt.
5. Những thói quen nên bỏ
System.gc()chỉ là gợi ý và với hầu hết collector sẽ gây một full GC dừng toàn bộ. Không gọi trong code ứng dụng; nếu thư viện bên thứ ba gọi, cân nhắc-XX:+DisableExplicitGC.finalize()đã bị đánh dấu loại bỏ (JEP 421) và không bao giờ nên là nơi giải phóng tài nguyên. Dùng try-with-resources; cần dọn khi object bị thu thì dùngjava.lang.ref.Cleaner.Gán
nulltràn lan không giúp GC; xóa phần tử khỏi collection sống lâu, đóng listener, hủy đăng ký callback mới là việc có ý nghĩa.OOM không luôn nghĩa là heap thiếu: Ngày 71 đã phân biệt
Java heap space,Metaspace,GC overhead limit exceededvàunable to create native thread. Đọc đúng thông điệp trước khi tăng-Xmx.
6. Tự kiểm tra
Chạy
Reachabilityvới-Xmx32m: vì sao chương trình vẫn chạy được dù mỗi vòng cấp phát 2 MB, và tới vòng bao nhiêu thìKEEPgây OOM?Chạy
GcDemolần lượt với-XX:+UseSerialGC, G1 và ZGC thế hệ, so sánh số lần GC và pause dài nhất trong log.Tăng
longLivedlên 4 MB mỗi khối: quan sát khi nào xuất hiệnHumongoustrong log G1 và tìm hiểu vì sao object lớn được G1 xử lý riêng.Với một service thật của bạn, bật
-Xlog:gc*:file=gc.logmột ngày rồi tìm đường đáy heap sau mỗi full GC.
Tóm lại: GC thu hồi thứ không còn với tới được từ root, dọn young rẻ và thường xuyên, dọn old đắt và hiếm; G1 là lựa chọn mặc định hợp lý, ZGC thế hệ khi cần độ trễ thấp; và log GC là công cụ chẩn đoán đầu tiên, trước mọi flag tuning. Ngày 73 ta rời heap để xem class được nạp vào JVM thế nào: ClassLoader, mô hình ủy quyền, và vì sao NoClassDefFoundError không giống ClassNotFoundException.
Tài liệu đối chiếu
Oracle HotSpot GC Tuning Guide (Java 21): https://docs.oracle.com/en/java/javase/21/gctuning/ ; JEP 439 Generational ZGC: https://openjdk.org/jeps/439 ; JEP 421 Deprecate Finalization: https://openjdk.org/jeps/421 ; JEP 158 Unified JVM Logging: https://openjdk.org/jeps/158
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


