Garbage Collection: ai dọn rác, dọn lúc nào

4 câu hỏi~25 phútTừ khóa: reachability, GC roots, mark-sweep-compact, generational hypothesis, Eden, Survivor, Old, minor/major/full GC, stop-the-world, G1, ZGC, Shenandoah, Parallel GC, MaxGCPauseMillis, -Xlog:gc

Tiếp đi: GC. Dashboard của order-service thỉnh thoảng có request bị chậm hẳn 400ms dù DB và Redis đều nhanh. Có phải do GC không, và GC làm việc thế nào?

Rất có thể. Trong một số pha, GC phải dừng mọi thread của app (stop-the-world). Nếu pause đó dài 400ms thì request nào đang chạy cũng chờ 400ms. Ta đi từ gốc.

1. Bài toán

p50 = 20 ms, p99 = 60 ms, nhưng p99.9 = 450 ms
Trace (Zipkin): span controller 430 ms, các span DB/Redis bên trong chỉ 8 ms
→ thời gian "mất" không nằm ở I/O

2. Vấn đề: Java không có free()

Mỗi request order-service tạo hàng nghìn object tạm (DTO, String, ArrayList, Jackson buffer). Bạn không giải phóng; GC phải tự tìm object nào không còn dùng và thu hồi, đồng thời không được dọn nhầm object đang dùng.

3. Giải pháp: reachability + generational

Object còn sống = đi tới được từ một GC root. Không phải đếm reference (nên vòng tham chiếu A↔B không cứu được nhau).

Generational hypothesis: đa số object chết rất trẻ (DTO của request), số ít sống lâu (bean, cache). Vì vậy chia heap:

┌──────────── Young ─────────────┬──────────── Old ────────────┐
│ Eden        │ S0  │ S1         │ object sống lâu (bean, cache,│
│ new Order() │ survivor        │ session, connection pool)   │
└─────────────┴─────┴───────────┴─────────────────────────────┘
Minor GC: dọn Young (nhanh, vì đa số đã chết)
object sống sót qua N lần (tuổi ≥ threshold) → promote sang Old
Major/Mixed: dọn Old.  Full GC: dọn toàn heap, thường đơn luồng/lâu → tránh

4. Từng bước

Bước 1: bật GC log (luôn bật trên production)

JAVA_TOOL_OPTIONS="-XX:+UseG1GC -XX:MaxRAMPercentage=75 \
  -Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m"

Bước 2: đọc GC log

[2026-10-14T10:01:12.331+0700][3601.2s] GC(812) Pause Young (Normal) (G1 Evacuation Pause) 612M->148M(1024M) 18.412ms
[2026-10-14T10:03:40.902+0700][3749.8s] GC(840) Pause Young (Concurrent Start) (G1 Humongous Allocation) 701M->402M(1024M) 25.1ms
[2026-10-14T10:03:41.512+0700][3750.4s] GC(841) Concurrent Mark Cycle 590.3ms
[2026-10-14T10:05:02.118+0700][3831.0s] GC(866) Pause Full (G1 Compaction Pause) 1010M->890M(1024M) 412.7ms  ← thủ phạm
ĐọcNghĩa
612M->148M(1024M)heap dùng trước → sau (tổng)
Pause Young 18msbình thường
Humongous Allocationobject ≥ nửa region (mảng lớn, export CSV) cấp phát thẳng vào Old
Concurrent Mark 590mschạy song song với app, không pause
Pause Full 412ms, sau GC còn 890Mheap gần đầy kể cả sau GC → thiếu heap hoặc leak (bài 2.5)

Bước 3: xem nhanh khi đang chạy

$ jstat -gcutil <pid> 1000
  S0   S1    E     O     M    YGC  YGCT   FGC  FGCT
  0.0 100.0 63.2  87.9  95.1  866  14.2    3   1.21
  # O (Old) 88% và tăng dần, FGC tăng → nguy

Bước 4: chỉnh có lý do

# G1: đặt mục tiêu pause, để G1 tự chỉnh young size
-XX:+UseG1GC -XX:MaxGCPauseMillis=100
# Pause vẫn cao vì heap lớn và cần p99.9 thấp → thử ZGC (Java 21: generational)
-XX:+UseZGC -XX:+ZGenerational   # Java 23+ ZGC mặc định generational

Không set -Xmn (young size cố định) khi dùng G1: nó vô hiệu hóa khả năng tự chỉnh để đạt pause target.

5. Cách hoạt động: G1 (mặc định từ Java 9)

Heap chia thành region bằng nhau (1–32MB):
┌──┬──┬──┬──┬──┬──┬──┬──┐
│E │E │S │O │O │H │H │  │  E=Eden S=Survivor O=Old H=Humongous
├──┼──┼──┼──┼──┼──┼──┼──┤
│O │E │  │O │E │O │  │E │
└──┴──┴──┴──┴──┴──┴──┴──┘
1. Young GC (STW): copy object sống từ Eden/S sang S/Old region trống
2. Old đầy ~45% (IHOP) → Concurrent Mark: đánh dấu song song với app
3. Mixed GC (STW ngắn): dọn Young + vài Old region nhiều rác nhất ("Garbage First")
4. Nếu dọn không kịp (cấp phát nhanh hơn dọn) → Full GC (STW dài) ✗

Stop-the-world: mọi thread app dừng ở safepoint
 app ═══════╗          ╔═══════
 GC         ╚══ pause ═╝         ← request đang chạy chờ theo

ZGC/Shenandoah làm cả việc di chuyển object song song với app (dùng load barrier / forwarding), pause chỉ còn dưới 1ms bất kể heap lớn bao nhiêu, đổi lại tốn thêm CPU.

6. So sánh các GC

GCMục tiêuPauseDùng khi
SerialĐơn giản, ít RAMDài, 1 threadContainer < 2 CPU / < 1792MB (JVM tự chọn), tool CLI
ParallelThroughputDài, nhiều threadBatch job, không quan tâm latency
G1 (mặc định)Cân bằngChục–trăm ms, có targetĐa số service web như order-service
ZGCLatency thấp< 1 msHeap lớn, yêu cầu p99.9 chặt
ShenandoahLatency thấp< 10 msTương tự ZGC (OpenJDK của Red Hat, Temurin)

Lưu ý: JVM tự chọn Serial nếu thấy ít hơn 2 CPU hoặc ít RAM. Pod limits.cpu: 1 → order-service chạy Serial GC mà bạn không biết. Ghi rõ -XX:+UseG1GC. (Cách JVM đọc limit container: Docker bài 16.3.)

7. Khi nào nên tune

Minor GC cũng stop-the-world à? Mình tưởng chỉ Full GC mới dừng app.

Bạn hiểu gần đúng, chỉnh một điểm: với G1, Parallel, Serial thì Young GC cũng là stop-the-world. Nó chỉ ngắn (vài–vài chục ms) vì Young nhỏ và đa số object đã chết, chỉ phải copy ít object sống.

Chi phí Young GC ∝ số object CÒN SỐNG (không phải số rác)
1 GB rác + 5 MB sống  → nhanh
1 GB với 600 MB sống  → chậm (vd: load 500k Order vào List trong 1 request)

Vì vậy một endpoint export tải hết đơn hàng vào RAM có thể làm Young GC của cả service chậm theo. Dùng phân trang / stream (bài 4.3.9).

Vậy cứ chọn ZGC cho nhanh luôn, pause dưới 1ms còn gì bằng?

Nghe hấp dẫn nhưng có giá:

Rule: đo trước. G1 mặc định; chỉ đổi sang ZGC khi GC log chứng minh pause là nguyên nhân vi phạm SLO latency, và chạy load test so sánh.

Phỏng vấn hỏi GC thì trả lời sao?

Q1. GC biết object nào là rác bằng cách nào?

🇻🇳 Tiếng Việt:

JVM dùng reachability, không dùng đếm reference. GC bắt đầu từ GC roots như biến local trên stack của các thread, static field, JNI reference, rồi đánh dấu mọi object đi tới được. Object không đi tới được là rác, kể cả khi chúng trỏ vòng vào nhau.

🇬🇧 English:

The JVM uses reachability, not reference counting. The GC starts from GC roots such as local variables on thread stacks, static fields and JNI references, and marks every object reachable from them. Anything unreachable is garbage, even if those objects reference each other in a cycle.

Q2. Giải thích generational GC và minor/major/full GC.

🇻🇳 Tiếng Việt:

Dựa trên giả thuyết đa số object chết trẻ, heap chia Young (Eden + Survivor) và Old. Minor GC dọn Young, nhanh vì chi phí tỷ lệ với số object còn sống. Object sống qua nhiều lần được promote sang Old. Major hoặc mixed GC dọn Old, Full GC dọn toàn heap và thường pause lâu nhất. Full GC lặp lại thường báo hiệu heap quá nhỏ hoặc leak.

🇬🇧 English:

Based on the hypothesis that most objects die young, the heap is split into Young (Eden plus Survivors) and Old. A minor GC collects Young and is fast because its cost depends on live objects. Objects that survive several collections are promoted to Old. A major or mixed GC collects Old, and a full GC collects the whole heap with the longest pause. Repeated full GCs usually mean the heap is too small or there is a leak.

Q3. G1 và ZGC khác nhau thế nào, chọn cái nào?

🇻🇳 Tiếng Việt:

G1 là mặc định, chia heap thành region, có pause target, dọn region nhiều rác trước; pause thường vài chục ms. ZGC di chuyển object song song với app nên pause dưới 1ms bất kể kích thước heap, đổi lại tốn thêm CPU. Em dùng G1 cho đa số service, chuyển sang ZGC khi GC log cho thấy pause làm vi phạm SLO latency, và luôn đo bằng load test.

🇬🇧 English:

G1 is the default: it splits the heap into regions, has a pause-time goal and collects the regions with the most garbage first, with pauses of tens of milliseconds. ZGC relocates objects concurrently, so pauses stay under a millisecond regardless of heap size, at the cost of extra CPU. I use G1 for most services and switch to ZGC when GC logs show pauses breaking the latency SLO, always validating with a load test.

Cách nhớ ngắn gọn


Tự kiểm tra

Hai object A↔B trỏ vào nhau, không ai khác trỏ tới: GC có dọn không?

Có, vì không reachable từ GC root.

Pause Full ... 1010M->890M(1024M) nói gì?

Sau Full GC heap vẫn gần đầy: thiếu heap hoặc leak.

Pod 1 CPU, không chỉ định GC, JVM dùng GC nào?

Serial GC (JVM không coi là "server-class machine").

Humongous allocation là gì?

Object ≥ nửa region của G1, cấp phát thẳng vào vùng humongous (thuộc Old), tốn kém.

Vì sao không nên set -Xmn với G1?

Nó cố định young size, G1 không tự điều chỉnh được để đạt MaxGCPauseMillis.