Backend

Garbage Collection trong Java: reachability, heap generations và vì sao vẫn memory leak

SSite Admin
20 tháng 08, 2026 7 phút đọc 1 lượt xem
Garbage Collection trong Java: reachability, heap generations và vì sao vẫn memory leak

Java không có free(). Bạn new thoải mái, còn việc trả bộ nhớ về heap do Garbage Collector (GC) đảm nhiệm — tự động, chạy nền, gần như vô hình. Chính vì vô hình nên nhiều lập trình viên Java làm việc nhiều năm mà chưa từng trả lời rõ ràng: GC quyết định thu hồi một object dựa trên tiêu chí gì? Vì sao heap chia thành các "thế hệ"? Và nếu GC tự động, tại sao ứng dụng vẫn OutOfMemoryError? Bài này trả lời tuần tự từng câu hỏi đó.

GC thực sự làm gì

Mọi object trong Java được cấp phát trên heap — vùng nhớ chung của cả tiến trình JVM. Khác với C/C++, lập trình viên không giải phóng bộ nhớ thủ công: GC định kỳ tìm những object không còn ai dùng và thu hồi chỗ của chúng. Vấn đề nằm ở định nghĩa "không còn ai dùng" — GC không đọc được ý định của bạn, nó chỉ có một tiêu chí cơ học duy nhất: reachability (khả năng với tới).

Reachability — tiêu chí duy nhất của GC

GC xuất phát từ một tập điểm neo gọi là GC roots: biến local trên stack của các thread đang chạy, static field của các class đã nạp, và tham chiếu JNI từ native code. Từ đó nó lần theo mọi tham chiếu — object nào với tới được là reachable và được giữ nguyên; phần còn lại là rác, bị thu hồi trong lần dọn kế tiếp.

Sơ đồ reachability: GC roots (biến local, static field, thread, JNI) lần theo tham chiếu tới chuỗi object reachable được giữ nguyên; cụm object không root nào với tới — kể cả khi trỏ vòng lẫn nhau — bị thu hồi trọn cụm
void process() {
    Order order = new Order(4821);   // reachable: biến local trên stack đang trỏ tới
    enrich(order);
}
// ra khỏi process(): không còn GC root nào với tới Order#4821
// → đủ điều kiện thu hồi, dù nó vẫn trỏ sang OrderItem, Customer...

class AppConfig {
    static final Map<String, String> SETTINGS = new HashMap<>();
    // static field là GC root: mọi thứ SETTINGS trỏ tới sống cùng class
    // → sống gần như trọn đời JVM
}

Hai hệ quả đáng chú ý. Thứ nhất, Java lần theo reachability chứ không đếm tham chiếu, nên hai object trỏ vòng lẫn nhau vẫn bị thu hồi bình thường khi không root nào với tới cụm đó — không có chuyện "rò rỉ do trỏ vòng" như reference counting thuần túy. Thứ hai, và quan trọng hơn: reachable không đồng nghĩa với đang dùng. Một object bạn đã xong việc nhưng vẫn bị một static field hay một collection sống dai trỏ tới sẽ không bao giờ được thu hồi — đây chính là gốc rễ của memory leak, ta quay lại ở phần cuối.

Heap generations — đa số object chết trẻ

Nếu mỗi lần dọn đều phải quét toàn bộ heap, chi phí sẽ tỷ lệ với kích thước heap — càng nhiều RAM càng chậm. May mắn là các ứng dụng thực tế tuân theo giả thuyết thế hệ (generational hypothesis): tuyệt đại đa số object chết rất trẻ — một DTO của request, một chuỗi tạm trong vòng lặp, một iterator. HotSpot tận dụng điều này bằng cách chia heap theo tuổi object.

Sơ đồ heap generations: Young Generation gồm Eden và hai Survivor space với Minor GC nhanh; object sống đủ số lần Minor GC được promotion sang Old Generation nơi Major/Full GC chậm và tốn kém; kèm chú thích Minor GC, Full GC và stop-the-world
  • Young Generation — object mới sinh nằm ở Eden; khi Eden đầy, một lần Minor GC quét riêng vùng này. Đa số object đã chết nên chi phí rất thấp — chỉ vài mili giây; kẻ sống sót được chuyển qua hai Survivor space luân phiên.

  • Promotion — object sống qua đủ số lần Minor GC (tenuring threshold) được thăng cấp sang Old Generation, nơi dành cho những thứ sống dai thật sự: cache, connection pool, singleton.

  • Old Generation — chỉ bị quét khi đầy dần, bằng Major/Full GC — đắt hơn hẳn vì phải quét vùng lớn. Full GC xuất hiện dày đặc là tín hiệu heap có vấn đề: hoặc thiếu dung lượng thật, hoặc có leak đang tích tụ.

GC pause và các collector hiện đại

Để quét chính xác, GC cần các thread ứng dụng đứng yên tại safepoint — khoảng dừng này gọi là stop-the-world pause. Với đa số ứng dụng, pause vài chục mili giây không ai nhận ra; nhưng với hệ thống latency nhạy cảm, một cú Full GC vài giây nghĩa là timeout hàng loạt. Toàn bộ lịch sử phát triển collector của JVM xoay quanh việc rút ngắn khoảng dừng này:

  • Serial GC — một thread dọn tất cả; hợp lý cho heap nhỏ và container ít CPU.

  • Parallel GC — nhiều thread cùng dọn, tối ưu tổng throughput; chấp nhận pause dài hơn, hợp với batch job.

  • G1 — mặc định từ Java 9: chia heap thành region, dọn dần từng phần để giữ pause quanh mục tiêu ~200ms; lựa chọn cân bằng cho đa số service.

  • ZGC — pause dưới 1ms gần như bất kể kích thước heap, nhờ dọn đồng thời với ứng dụng. Java 21 bổ sung generational ZGC (JEP 439), đem lợi thế thế hệ vào ZGC — bật bằng -XX:+UseZGC -XX:+ZGenerational.

Nguyên tắc chọn: giữ mặc định G1 cho tới khi số liệu chứng minh điều ngược lại. Chuyển sang ZGC khi heap lớn và độ trễ đuôi (tail latency) là yêu cầu cứng; chuyển sang Parallel khi chỉ quan tâm tổng thời gian chạy batch.

Memory leak trong ngôn ngữ có GC

GC chỉ thu hồi thứ unreachable — vậy nên memory leak trong Java có một định nghĩa riêng: object đã xong việc nhưng vẫn reachable ngoài ý muốn, khiến GC không được phép động vào. Chúng tích tụ dần trong Old Generation, Full GC chạy ngày càng dày, và kết cục là OutOfMemoryError. Bốn thủ phạm phổ biến nhất:

  • Collection static chỉ thêm không bớt — static field là GC root, mọi thứ nó giữ sống trọn đời JVM.

  • Cache không giới hạnHashMap tự chế làm cache không có eviction; phải dùng cache có maximumSize và thời gian hết hạn.

  • Listener/callback quên gỡ — đăng ký vào một đối tượng sống dai (event bus, scheduler) mà không hủy đăng ký, listener và mọi thứ nó trỏ tới bị giữ theo.

  • Tham chiếu sống dai ngoài ý muốnThreadLocal không remove() trên thread pool là ví dụ kinh điển: thread của pool không chết, giá trị gắn theo nó cũng vậy.

class SearchService {
    // Leak kinh điển: collection static chỉ có thêm, không bao giờ bớt
    private static final Map<String, SearchResult> CACHE = new HashMap<>();

    SearchResult search(String query) {
        // mỗi query mới = một entry sống mãi — CACHE là GC root giữ tất cả
        return CACHE.computeIfAbsent(query, this::doSearch);
    }
}

// Cách sửa: cache phải CÓ GIỚI HẠN — ví dụ với Caffeine
Cache<String, SearchResult> cache = Caffeine.newBuilder()
        .maximumSize(10_000)                      // chặn tăng trưởng vô hạn
        .expireAfterWrite(Duration.ofMinutes(10)) // entry cũ tự hết hạn
        .build();

Vận hành: theo dõi và phòng ngừa

Khác biệt giữa "biết GC" và "vận hành được GC" nằm ở thói quen đo đạc. Ba việc đáng làm ngay trên mọi service production:

  • Ghi log GC-Xlog:gc* gần như miễn phí; tần suất và độ dài pause của Full GC là chỉ số sức khỏe heap trung thực nhất.

  • Heap dump khi OOM-XX:+HeapDumpOnOutOfMemoryError để khi sự cố xảy ra còn có hiện trường mà phân tích (Eclipse MAT, VisualVM); thiếu nó, OOM lúc 3 giờ sáng không để lại dấu vết.

  • Đặt kích thước heap theo tải thực-Xms/-Xmx dựa trên số liệu, không đoán; heap quá nhỏ gây GC dồn dập, quá lớn lãng phí RAM và kéo dài pause của collector không đồng thời.

# Cấu hình JVM tối thiểu nên có trên production (Java 21)
java -Xms512m -Xmx2g \
     -Xlog:gc*:file=gc.log:time,uptime \
     -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/var/dumps \
     -jar app.jar

# Cần pause cực ngắn (heap lớn, latency nhạy cảm)? Bật generational ZGC:
#   -XX:+UseZGC -XX:+ZGenerational   (JEP 439, Java 21)

Kết luận

GC tự động hóa việc thu hồi, nhưng không tự động hóa việc giữ đúng thứ cần giữ — đó vẫn là trách nhiệm của người viết code. Nắm được bốn khái niệm nền — reachability là tiêu chí duy nhất, heap chia thế hệ vì đa số object chết trẻ, pause là cái giá của việc dọn dẹp, và leak nghĩa là reachable ngoài ý muốn — bạn đọc được log GC, khoanh vùng được OOM, và chọn được collector phù hợp thay vì tin vào may mắn. Với tuyệt đại đa số ứng dụng, công thức là: giữ G1 mặc định, log GC từ ngày đầu, cache có giới hạn, và một heap dump sẵn sàng cho ngày xấu trời.

S

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.

Bài viết liên quan

99 Ngày Spring — Ngày 23: Spring Data JPA repository

Ngày 23 của 99 Ngày Spring: Spring Data JPA repository — JpaRepository với bộ CRUD có sẵn trên một interface trống, phả hệ Repository → CrudRepository → JpaRepository và cơ chế proxy trên SimpleJpaRepository, derived query sinh truy vấn từ tên method với lưới fail-fast lúc khởi động.

19 thg 8, 20266 phút11
99 Ngày Java — Ngày 23: Enum

Ngày 23 của 99 Ngày Java: enum — tập giá trị cố định an toàn kiểu với == đáng tin, lớp đầy đủ với field, constructor và method, switch expression exhaustive biến compiler thành lưới an toàn khi thay đổi, cùng EnumMap và EnumSet chuyên dụng — và quy tắc lưu trữ theo name() thay vì ordinal.

19 thg 8, 20266 phút6
99 Ngày Java — Ngày 22: Wrapper class & autoboxing

Ngày 22 của 99 Ngày Java: wrapper class và autoboxing — cầu nối tự động giữa primitive và đối tượng, Integer cache với hiện tượng == đúng ở 127 sai ở 128, bẫy unboxing null gây NPE, boxing thầm lặng trong vòng lặp, và cách chọn giữa parseInt với valueOf.

18 thg 8, 20266 phút33