Java Internals & Concurrency/Garbage Collection — reachability và ba thuật toán nền tảng
58/75
Bài 58 / 75~14 phútJVM InternalsMiễn phí lượt xem

Garbage Collection — reachability và ba thuật toán nền tảng

GC reclaim object không reachable từ GC root — không phải khi refcount về 0. Ba thuật toán mark-sweep/copy/compact và generational hypothesis chia young/old.

TL;DR: GC coi một object là rác khi không còn đường reference nào từ GC root (static field, stack local, JNI, thread) dẫn tới nó — không phải khi reference count về 0, nên GC xử lý được cả cycle. Trên nền reachability, JVM dựng ba thuật toán nền: mark-sweep (đơn giản, để lại fragmentation), mark-copy (chép live sang nửa heap trống, không fragment nhưng tốn nửa bộ nhớ), mark-compact (dồn live về đầu vùng, không fragment nhưng tốn chi phí dịch). Generational hypothesis — phần lớn object chết trẻ — cho phép chia heap thành young (mark-copy, GC thường xuyên) và old (mark-compact, GC hiếm). Tên collector cụ thể (Parallel, G1, ZGC) chỉ là cách phối ba thuật toán này.

Một service đang chạy êm bỗng có latency p99 nhảy từ 20ms lên 300ms mỗi vài phút, đúng nhịp. Không có request nào chậm ở tầng DB hay network — thời gian bốc hơi bên trong JVM. Thủ phạm quen mặt: garbage collector dừng app thread để dọn heap. Muốn chọn đúng collector và tune nó, trước hết phải hiểu GC quyết định object nào là rác bằng cách nào, và ba thuật toán nền mà mọi collector đều lắp ghép từ đó.

Học xong, bạn Explain được GC quyết định object nào là rác bằng reachability, và ba thuật toán nền mà mọi collector lắp ghép — nền để bài 06b chọn collector theo SLA và bài 12 mổ sâu G1.

1. Analogy — thư viện tự dọn sách

Hình dung thư viện nơi bạn không bao giờ trả sách: rút sách ra bàn đọc, đọc xong bỏ đó. Thủ thư (GC) định kỳ đi một vòng, giữ sách còn có người đang cầm và đem về kho sách không ai đụng tới. Điểm mấu chốt: thủ thư không đếm "cuốn này được mượn mấy lần" mà truy vết từ những người đang ngồi đọc (GC root) xem tay họ với tới cuốn nào. Một chồng hai cuốn kẹp vào nhau ở góc phòng mà không ai cầm — dù "tham chiếu" lẫn nhau — vẫn bị dọn.

Thư việnGC
Người đang ngồi đọcGC root (stack local, static field, active thread)
Sách trong tay người đọcObject reachable → live
Chồng sách góc phòng không ai cầmObject unreachable → rác
Thủ thư đi một vòng truy vếtReachability traversal
Đóng cửa phòng lúc dọnStop-the-world pause
💡 Cách nhớ

GC không hỏi "object này được trỏ tới mấy lần" mà hỏi "từ GC root có đường nào tới nó không". Reachable thì giữ, unreachable thì thu hồi — kể cả khi hai object rác trỏ vòng vào nhau.

2. Khi nào một object bị coi là rác?

Object là rác khi không có chuỗi reference nào dẫn từ một GC root đến nó. GC root là các điểm neo mà runtime chắc chắn còn sống: static field của class đã load, biến local trên stack của mọi thread, JNI reference, và active thread object.

Từ tập root, GC traverse đồ thị reference (BFS/DFS). Object chạm được = live; không chạm được = dead, có thể reclaim.

Stack / Static (GC root)
   |
   v
[A] --> [B] --> [C]
         \
          [D] --> [E]

[F] <--> [G]   (khong co duong tu root -> rac, du F va G tro vao nhau)

A B C D E đều live vì có đường từ root. F và G trỏ vào nhau nhưng không root nào trỏ tới — cả hai là rác.

Đây là lý do Java dùng reachability thay vì reference counting thuần (cơ chế chính của CPython). Reference counting đếm số reference trỏ tới mỗi object, về 0 thì free ngay — nhưng bó tay với cycle: F và G giữ counter của nhau luôn ≥ 1 dù không ai bên ngoài dùng chúng → không tự dọn được (chính vì thế CPython phải bổ sung một cycle detector riêng chạy nền để vá). Reachability đi từ root, không tới được F/G thì thu hồi, bất kể counter.

Đánh đổi: reference counting free tất định (không pause) nhưng leak cycle và mỗi phép gán tốn tăng/giảm counter. Reachability xử lý cycle và cho allocation cực rẻ, đổi lại cần quét heap theo đợt nên latency biến thiên. Java nhắm server nên chọn reachability.

3. Ba thuật toán nền tảng

Sau khi biết object nào live, GC cần thu hồi vùng nhớ của object dead. Có ba cách nền, mọi collector đều lắp ghép từ đây.

3.1. Mark-Sweep

flowchart LR
    A["Mark: traverse tu root<br/>danh dau object live"] --> B["Sweep: quet toan heap<br/>free o nho khong danh dau"]
    style A fill:#93C5FD
    style B fill:#6EE7B7

Mark: đi từ root, đánh dấu mọi object chạm được. Sweep: quét tuyến tính toàn heap, trả lại vùng nhớ của object không đánh dấu. Đơn giản, nhưng để lại fragmentation: object dead nằm rải rác nên vùng trống rời rạc — tổng free có thể đủ mà không khối liền mạch nào chứa nổi một object lớn.

3.2. Mark-Copy (hiện thực kinh điển: thuật toán Cheney)

flowchart TB
    A["Heap chia doi: From + To"] --> B["Chep object live<br/>tu From sang To (lien mach)"]
    B --> C["Xoa sach ca vung From"]
    C --> D["Doi vai tro From <-> To"]
    style B fill:#93C5FD
    style C fill:#6EE7B7

Chia heap thành hai nửa FromTo; app chỉ alloc trong From. Khi GC: chép mọi object live từ From sang To (gom liền mạch), xoá nguyên nửa From một lần, rồi đổi vai trò. Ưu: không fragmentation, allocation nhanh (bump-pointer — chỉ dịch con trỏ top lên đúng kích thước object, không dò ô trống rải rác). Nhược: chỉ dùng được nửa heap. Rất hợp vùng ít object live — chép ít là rẻ.

3.3. Mark-Compact

flowchart LR
    A["Mark: danh dau object live"] --> B["Compact: don live ve dau vung<br/>o trong dong lai lien mach"]
    style A fill:#93C5FD
    style B fill:#6EE7B7

Mark như mark-sweep, nhưng thay vì sweep thì compact: dịch mọi object live về đầu vùng, dồn ô trống thành khối liền mạch. Ưu: không fragmentation, không phí nửa heap. Nhược: tốn chi phí dịch objectsửa mọi reference trỏ tới object vừa dịch. Hợp vùng nhiều object live.

Thuật toánFragmentationChi phí chínhHợp với
Mark-sweepSweep tuyến tínhVùng ít thay đổi
Mark-copyKhôngPhí nửa heapVùng ít live (young)
Mark-compactKhôngDịch + sửa referenceVùng nhiều live (old)

4. Generational hypothesis — chia young/old

Quan sát thực nghiệm từ thập niên 1980: 90-95% object chết trẻ — alloc xong dùng vài microsecond rồi mất reference (object trung gian trong stream, biến local, wrapper tham số). Chỉ 5-10% sống lâu (cache, singleton, state dài hạn).

Tận dụng pattern này để khỏi quét toàn heap mỗi lần: chia heap thành hai vùng, mỗi vùng một chiến lược.

  • Young: GC thường xuyên, dùng mark-copy — hầu hết đã chết, chép phần live nhỏ là rẻ.
  • Old: GC hiếm, dùng mark-compact — object già đa số còn sống, chép sẽ đắt.
Heap (generational):
+-----------------------+--------------------+
|         Young         |        Old         |
+-------+-------+-------+--------------------+
| Eden  |  S0   |  S1   |      Tenured       |
+-------+-------+-------+--------------------+

Object mới luôn sinh trong Eden; hai vùng Survivor (S0/S1) là "To/From" của mark-copy cho young.

Minor GC chỉ quét young: chép live sang Survivor, promote object đủ già sang Old. Nhanh (vài ms) vì phần lớn Eden là rác. Full GC quét toàn heap (young + old, và cả Metaspace), chậm hơn nhiều (hàng trăm ms với heap lớn), thường trigger khi Old đầy — đây là loại pause hay gây breach SLA, và monitor tần suất của nó là việc bắt buộc. (Log HotSpot dùng nhãn "Pause Young" cho minor và "Pause Full" cho full, cùng vài nhãn khác tùy collector — bài 06b đọc kỹ các nhãn này.)

4.1. Tenuring — khi nào thăng cấp

Object phải sống sót qua bao nhiêu vòng minor GC trước khi promote sang Old? Mỗi lần sống sót, tuổi tăng 1; chạm ngưỡng thì lần GC sau chuyển sang Old. HotSpot đặt trần qua -XX:MaxTenuringThreshold (mặc định 15 — và là trần: JVM có thể tự hạ ngưỡng thực tế khi Survivor chật, để promote sớm).

💡 Thử đoán

Nếu Survivor liên tục đầy và JVM buộc promote object còn rất trẻ sang Old, tần suất Full GC thay đổi thế nào? Tự trả lời trước khi đọc tiếp.

Promote sớm nghĩa là object đáng lẽ chết trong young lại nằm ở Old → Old đầy nhanh hơn → Full GC thường xuyên hơn → pause dài xuất hiện nhiều hơn. Đây là một trong những nguyên nhân "GC pause tăng dần" mà bài 06b dạy cách nhận ra từ GC log.

5. Collector — chỉ là cách phối ba thuật toán

Các collector JVM 21 cung cấp đều xây trên ba thuật toán nền + generational hypothesis; điểm khác nhau là làm việc song song với app tới đâu và do đó pause dài bao nhiêu:

CollectorNền tảngPause profile điển hìnhChi tiết ở
SerialCopy (young) + compact (old), 1 threadDài, đơn giản, overhead thấp
ParallelNhư Serial nhưng nhiều threadDài nhưng throughput cao
G1Copy + compact, phần lớn concurrent50-200ms (soft target)Bài 12
ZGC / ShenandoahConcurrent compactionDưới ~1ms (ZGC), dưới ~10ms (Shenandoah)Bài 12b

Bài này dừng ở mức tên gọi và pause profile. Cơ chế riêng của từng collector nằm ở bài 12 (G1) và 12b (ZGC/Shenandoah); cách chọn collector theo SLA và đọc GC log ở bài 06b.

6. Pitfall tổng hợp

Nhầm 1: "Java có GC nên không bao giờ memory leak."

static final List<byte[]> CACHE = new ArrayList<>();
// Moi request add vao CACHE, khong bao gio remove
// -> object VAN reachable tu static field -> GC khong the thu hoi -> leak

✅ GC chỉ thu hồi object unreachable. Object còn reference từ root (collection static, listener chưa gỡ, ThreadLocal chưa clear) vẫn "sống" mãi. Leak trong Java = giữ reference ngoài ý muốn.

Nhầm 2: Gọi System.gc() để "dọn ngay cho chắc".

System.gc();   // Hint - JVM co the ignore, thuong trigger Full GC -> pause dai

✅ Tin heuristic của GC. System.gc() thường ép Full GC ngoài lịch → pause dài vô ích. Tắt hẳn bằng -XX:+DisableExplicitGC.

7. 📚 Deep Dive Oracle

📚 Deep Dive Oracle

Spec / reference chính thức:

Ghi chú: Generational hypothesis là nền của gần như mọi collector HotSpot; đọc chương layout trong GC Tuning Guide trước khi tune bất cứ flag nào. Ba thuật toán nền là kiến thức bất biến — collector mới chỉ thay đổi cách chạy chúng concurrent với app.

8. Liên hệ các bài khác

9. Tóm tắt

  • GC coi object là rác khi không reachable từ GC root (static field, stack local, JNI ref, active thread). Reachability xử lý được cycle; reference counting thì không.
  • Mark-sweep để lại fragmentation; mark-copy không fragment nhưng phí nửa heap (hợp vùng ít live); mark-compact không fragment nhưng tốn dịch + sửa reference (hợp vùng nhiều live).
  • Generational hypothesis: 90-95% object chết trẻ → chia young (mark-copy, minor GC nhanh, thường) + old (mark-compact, full GC chậm, hiếm).
  • Tenuring: sống sót đủ vòng minor GC (MaxTenuringThreshold mặc định 15) thì promote; Survivor chật ép promote sớm → Old đầy nhanh → Full GC nhiều.
  • Collector cụ thể chỉ khác nhau ở cách phối ba thuật toán và mức concurrent — cơ chế ở bài 12/12b, cách chọn ở bài 06b.

10. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao Java dùng reachability thay vì reference counting như CPython?

Reference counting free object khi counter về 0, nhưng bó tay với cycle: hai object trỏ vào nhau giữ counter của nhau luôn ≥ 1 dù không ai bên ngoài dùng → leak. Reachability đi từ GC root và thu hồi mọi object không chạm được, nên cycle không reachable vẫn bị dọn.

Đánh đổi: reference counting free tất định (không pause) nhưng leak cycle; reachability xử lý cycle và allocation cực rẻ nhưng latency biến thiên vì quét theo đợt. Java nhắm server nên chọn reachability.

Q2
So sánh mark-copymark-compact: mỗi thuật toán hợp với vùng heap nào và vì sao?

Mark-copy chép object live sang nửa heap trống; chi phí tỉ lệ số object live nhưng phí nửa heap — hợp young vì live ít, chép rẻ. Mark-compact dồn live về đầu vùng rồi sửa reference; không phí nửa heap nhưng tốn dịch + fix-up — hợp old vì object già đa số còn sống (mark-copy sẽ chép gần hết, quá đắt). Cả hai đều không để lại fragmentation, khác mark-sweep.

Q3
Vì sao chia heap thành young và old lại cho phép cycle GC phổ biến (minor GC) rẻ hơn hẳn so với quét toàn heap mỗi lần?

Quan sát thực nghiệm: 90-95% object chết trẻ, chỉ số ít sống lâu. Tối ưu: chia heap young (Eden + Survivor) và old (Tenured), object mới sinh ở Eden. Cycle phổ biến (minor GC) chỉ quét young — nhỏ, phần lớn đã chết nên mark-copy rẻ; chỉ object đủ già mới promote sang old (GC hiếm). Thay vì quét toàn heap mỗi lần, đa số cycle chỉ động vào một phần nhỏ.

Q4
Vì sao promote object quá sớm sang Old lại nguy hiểm?

Khi Survivor chật, JVM hạ ngưỡng tenuring thực tế và promote cả object còn trẻ — những object đáng lẽ chết trong young lại nằm ở Old. Hệ quả: Old đầy nhanh hơn → Full GC (pause dài) trigger thường xuyên hơn, cho triệu chứng "pause tăng dần theo thời gian". Sửa gốc: giảm allocation rate hoặc chỉnh kích thước young/Survivor cho object có đủ vòng minor GC để chết trước khi bị promote.

Q5
Một dev nói "code Java của tôi không thể memory leak vì có GC". Phản biện thế nào?

GC chỉ thu hồi object unreachable từ GC root. Object còn reference ngoài ý muốn vẫn reachable → GC không đụng tới → leak. Nguồn phổ biến: collection static chỉ add không remove; listener không huỷ đăng ký; ThreadLocal không clear trong thread pool; cache không giới hạn. GC bỏ được leak kiểu "quên free()" của C nhưng tạo lớp leak mới kiểu "quên buông reference"; chẩn đoán bằng heap dump.

Q6
Vì sao nói "Parallel, G1, ZGC chỉ là cách phối ba thuật toán nền"?

Cả ba đều dựa trên reachability + generational hypothesis + mark/copy/compact. Khác biệt chính là mức độ chạy song song với app, thể hiện ở pause profile: Parallel stop-the-world toàn bộ (throughput cao, pause dài); G1 giữ pause ở mức soft target (khoảng 50-200ms); ZGC kéo pause xuống dưới ~1ms và Shenandoah dưới ~10ms, mọi cỡ heap, đổi lấy throughput thấp hơn. Ba thuật toán nền không đổi; cái đổi là làm chúng lúc app dừng hay lúc app vẫn chạy — cơ chế cụ thể (region, barrier, concurrent phase) thuộc bài 12 và 12b.

Bài tiếp theo: Chọn GC collector theo SLA và đọc GC log

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

Chọn GC collector theo SLA latency và đọc GC log xác nhận