Backend

99 Ngày Java — Ngày 72: Garbage Collection

SSite Admin
7 tháng 10, 2026 9 phút đọc 4 lượt xem
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.

Garbage Collection: reachability, minor/major GC, G1 và ZGC, đọc log GC

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; }
}
  • temp chỉ sống trong frame của process(); frame biến mất thì đường đi từ root cũng biến mất. Bạn không cần gán null cho biến local trước khi hàm kết thúc.

  • KEEP là 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 → a khô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 App
  • Serial: 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->3M nghĩ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 Cycle là G1 đánh dấu old generation song song với ứng dụng; chỉ Pause Remark và Pause Cleanup là dừng ứng dụng, và rất ngắn. Dòng Pause Full xuất hiện nhiều mới là điều đáng lo.

  • Trên production, ghi ra file kèm time,uptime và xoay vòng bằng filecount/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ùng java.lang.ref.Cleaner.

  • Gán null trà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 exceeded và unable to create native thread. Đọc đúng thông điệp trước khi tăng -Xmx.

6. Tự kiểm tra

  • Chạy Reachability vớ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ì KEEP gây OOM?

  • Chạy GcDemo lầ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 longLived lên 4 MB mỗi khối: quan sát khi nào xuất hiện Humongous trong 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.log mộ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

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 72: Spring Cloud Config

Cấu hình tập trung trong Git, Config Server và spring.config.import, refresh khi đang chạy với @RefreshScope và Spring Cloud Bus, mã hóa secret bằng {cipher}.

7 thg 10, 20267 phút3
99 Ngày Spring — Ngày 71: Microservices tổng quan

Monolith và microservices khác nhau ở đâu, được gì và trả giá gì, khi nào không nên tách, và vì sao nên bắt đầu bằng modular monolith với Spring Modulith.

6 thg 10, 20269 phút8
99 Ngày Java — Ngày 71: Bộ nhớ JVM — stack, heap, metaspace

Bản đồ bộ nhớ JVM trên Java 21: stack và frame của từng luồng, heap chia young/old, metaspace ngoài heap, và cách đọc đúng từng loại OutOfMemoryError.

6 thg 10, 202611 phút8