Bộ nhớ/Ref-counting vs tracing GC — hai cách tìm rác
23/26
Bài 23 / 26~18 phútQuản lý bộ nhớ ngôn ngữ bậc caoMiễn phí lượt xem

Ref-counting vs tracing GC — hai cách tìm rác

Ref-counting giải phóng tức thì nhưng bỏ sót chu trình; tracing GC bắt được mọi rác nhưng gây stop-the-world pause. Mark-and-sweep và generational GC.

TL;DR: Có hai họ GC chính. Reference counting (CPython, Swift ARC) gắn một bộ đếm vào mỗi object: mỗi lần tạo thêm tham chiếu thì tăng, mỗi lần xoá tham chiếu thì giảm; về 0 thì giải phóng ngay. Deterministic, không pause — nhưng không bắt được chu trình tham chiếu (hai object trỏ nhau, đếm mãi không về 0). Tracing GC (Java, Go, JS) định kỳ trace từ root tìm toàn bộ reachable object (mark), phần còn lại là rác (sweep). Bắt được mọi chu trình — nhưng gây stop-the-world pause và cần headroom bộ nhớ. Generational GC tối ưu bằng giả thuyết "hầu hết object chết trẻ": chia heap thành young/old, GC young thường xuyên với chi phí thấp, GC old ít hơn.

Bạn viết một class cache giữ tham chiếu tới từng phần tử đã xử lý — trong CPython, phần tử được giải phóng ngay lập tức khi bạn xoá khỏi cache. Nhưng nếu phần tử trỏ ngược lại cache, cả hai sẽ không bao giờ được giải phóng dù bạn xoá toàn bộ — ref-counting bị mù trước chu trình. Trong Java, GC sẽ thu cả hai dù có chu trình, nhưng có thể dừng chương trình vài chục millisecond để làm điều đó. Hai cơ chế, hai tradeoff căn bản — bài này mổ xẻ cả hai.

1. Analogy — đếm khách vs kiểm tra phòng trống

Hình dung quản lý phòng họp. Cách 1 — đếm khách: cửa phòng có một bộ đếm. Mỗi người vào tăng 1, ra giảm 1. Khi đếm về 0, tắt đèn ngay. Nhanh, tức thì — nhưng nếu hai người cứ đứng chờ nhau ra trước, đếm không bao giờ về 0 dù phòng thực tế bỏ phí. Cách 2 — kiểm tra phòng trống: cứ mỗi giờ, nhân viên đi kiểm tra toàn bộ toà nhà từ lối vào chính, đánh dấu mọi phòng thật sự có người dùng; phòng không đánh dấu thì tắt đèn. Tốn thời gian kiểm tra, trong lúc đó ai cũng phải dừng — nhưng không bao giờ bỏ sót phòng trống dù người dùng đứng thành vòng tròn chờ nhau.

Quản lý phòng họpGC
Bộ đếm người vào/raReference count
Đếm về 0 → tắt đèn ngayCount về 0 → giải phóng tức thì
Hai người chờ nhau, đếm mắc kẹtReference cycle — không bao giờ về 0
Kiểm tra toà nhà từ lối vào chínhTracing từ root set
Đánh dấu phòng đang dùngMark reachable objects
Tắt đèn phòng không đánh dấuSweep unreachable
Dừng toàn bộ để kiểm tra chính xácStop-the-world pause
Cách nhớ

Ref-counting trả lời câu hỏi liên tục, sau mỗi lần gán: "còn ai giữ object này không?". Tracing GC trả lời câu hỏi định kỳ, từ đầu: "object nào có thể đến được từ root?". Hai cách hỏi khác nhau dẫn tới hai tradeoff khác nhau.

2. Reference counting — giải phóng tức thì, bù bằng chi phí mỗi lần gán

Mỗi object có một trường refcount (số nguyên). Bất cứ khi nào:

  • Một biến mới trỏ tới object → refcount++
  • Một biến ngừng trỏ (gán lại, ra scope, xoá) → refcount--
  • refcount == 0 → không còn ai giữ → giải phóng ngay lập tức
flowchart LR
  X["bien x"] --> A["obj A<br/>refcount=2"]
  Y["bien y"] --> A
  B["obj B<br/>refcount=0<br/>→ GIAI PHONG"]

CPython dùng ref-counting làm cơ chế GC chính. Mỗi phép gán trong Python có chi phí refcount++/refcount-- đi kèm. Khi refcount về 0, Python gọi destructor và giải phóng tại chỗ, không chờ chu kỳ GC.

Ưu điểm: giải phóng deterministic — biết chính xác khi nào destructor chạy. Không có stop-the-world pause. Phân bổ chi phí GC đều theo thời gian (mỗi lần gán trả một ít, không trả gộp).

Nhược điểm chính — reference cycle:

# Chu trinh tham chieu trong Python
class Node:
    def __init__(self):
        self.other = None

a = Node()
b = Node()
a.other = b   # a tro toi b, refcount(b) = 1
b.other = a   # b tro toi a, refcount(a) = 1

del a         # refcount(a) giam... xuong 1 (b van tro a) -- KHONG VE 0
del b         # refcount(b) giam... xuong 1 (a van tro b) -- KHONG VE 0
# Ca hai bien a va b da bi xoa -- nhung object van ton tai tren heap
# Khong the giai phong -- LEAK

Sau del adel b, không có biến nào trong code trỏ tới hai object đó nữa. Nhưng chúng trỏ lẫn vào nhau — refcount của mỗi cái vẫn là 1. Ref-counting không thu được. CPython phải thêm một cycle collector riêng (module gc) chạy định kỳ để phát hiện và thu chu trình — nghĩa là CPython thực ra dùng cả hai cơ chế.

3. Tracing GC — mark-and-sweep từng bước

Tracing GC không đếm tham chiếu. Thay vào đó, định kỳ nó trace toàn bộ đồ thị object từ root.

Mark phase: bắt đầu từ root set (stack, static, thanh ghi), duyệt theo mọi tham chiếu, đánh dấu mọi object đến được là "live".

Sweep phase: quét toàn bộ heap; object không có dấu "live" là rác — thu hồi.

flowchart TD
  subgraph roots["Root set"]
    R1["stack: bien list"]
    R2["static: config"]
  end

  subgraph heap["Heap"]
    A["Object A<br/>✓ marked"]
    B["Object B<br/>✓ marked"]
    C["Object C<br/>✓ marked"]
    D["Object D<br/>✗ unreachable"]
    E["Object E<br/>✗ unreachable"]
    D -- "D tro E" --> E
  end

  R1 --> A
  A --> B
  A --> C
  R2 --> C

  style D fill:#fee2e2,stroke:#ef4444
  style E fill:#fee2e2,stroke:#ef4444

DE trỏ nhau (chu trình) nhưng không reachable từ root → cả hai bị sweep. Tracing GC bắt được chu trình mà ref-counting không bắt được.

Vì sao phải stop-the-world? Trong khi GC đang trace, nếu ứng dụng vẫn chạy và thay đổi đồ thị tham chiếu, GC có thể đánh dấu sai (mark object còn sống là rác, hoặc bỏ qua object vừa trở thành unreachable). GC đơn giản nhất dừng tất cả thread ứng dụng (stop-the-world) trong suốt mark+sweep để đảm bảo consistency.

Thread ung dung: [=====chay=====|PAUSE|=====chay=====|PAUSE|...]
GC thread:                      [mark+sweep]         [mark+sweep]

Với heap nhỏ, pause vài ms. Heap lớn (vài GB), pause có thể đến hàng trăm ms với GC cũ.

4. Generational GC — khai thác "object chết trẻ"

Quan sát thực nghiệm: phần lớn object có lifetime rất ngắn — object tạm trong một hàm, intermediate result, request object trong web server. Một số nhỏ object sống lâu (cache, singleton, connection pool).

Generational hypothesis: hầu hết object chết trẻ (young) — nên tập trung GC vào đó.

Heap được chia thành young generation (mới cấp phát) và old generation (đã sống qua nhiều lần GC):

  • Minor GC (young): chạy thường xuyên, chỉ quét young generation. Rất nhanh (young gen nhỏ, hầu hết object đã chết → ít live object để copy). Pause ngắn.
  • Major GC / Full GC (old): quét cả heap. Hiếm hơn, pause dài hơn. Object sống qua đủ lần minor GC được promote lên old gen.
flowchart LR
  NEW["new Object()"] --> EDEN["Eden (young)"]
  EDEN -- "con song sau minor GC" --> S1["Survivor 1"]
  S1 -- "con song sau nhieu lan GC" --> OLD["Old Generation"]
  EDEN -- "chet" --> DEAD1["❌ thu hoi"]
  S1 -- "chet" --> DEAD2["❌ thu hoi"]
  OLD -- "major GC" --> DEAD3["❌ thu hoi"]

Java HotSpot chia young generation thành Eden và hai Survivor space (S0, S1). Mỗi minor GC copy object còn sống từ Eden+S0 sang S1 (hoặc ngược lại), giải phóng toàn bộ Eden và Survivor cũ trong một thao tác — hiệu quả vì hầu hết là rác.

5. Bảng so sánh

Tiêu chíReference countingTracing GC
Thời điểm thuNgay khi refcount về 0Định kỳ (khi heap đầy / theo lịch)
Chu trình tham chiếuKhông thu đượcThu được
PauseKhông có STW pause lớnStop-the-world pause (ms đến trăm ms)
Chi phí per-assignmentCó (inc/dec refcount mỗi gán)Không có per-assignment overhead
Headroom bộ nhớ cầnThấpCần headroom (thường 2-3x live set)
Ví dụCPython, Swift ARC, Rust ArcJava, Go, V8/JS, Ruby

6. Pitfall tổng hợp

Pitfall 1 — Ref-counting cycle leak. Pattern phổ biến: parent giữ list con, con giữ tham chiếu ngược tới parent. Trong CPython, cả cây sẽ không bao giờ được giải phóng qua ref-counting (cần cycle collector). Fix: dùng weakref cho chiều ngược — weak reference không tăng refcount.

Pitfall 2 — Tracing GC pause ở latency-nhạy. Hệ thống cần latency dưới 10ms (trading, game, real-time API) dễ bị GC pause vượt SLA. Giải pháp: dùng GC low-latency (G1, ZGC, Shenandoah cho Java; Go GC với pause dưới 1ms), hoặc giảm allocation rate để giảm tần suất GC.

Pitfall 3 — Nhầm "GC nào cũng như nhau". CPython gc bổ sung (cycle collector) khác Java G1 khác Go GC khác Swift ARC. Hành vi pause, tần suất, headroom bộ nhớ khác nhau nhiều. Khi tối ưu GC, phải biết mình đang dùng GC nào và cơ chế cụ thể của nó.

7. Đào sâu (tuỳ chọn)

Đào sâu (tuỳ chọn)

Tri-color marking và write barrier: GC hiện đại không dừng hoàn toàn — chúng dùng thuật toán tri-color marking (trắng = chưa thăm, xám = đang xử lý, đen = đã mark xong cùng con của nó) để mark đồng thời với ứng dụng. Để đảm bảo object không bị miss khi ứng dụng thay đổi đồ thị trong lúc mark, runtime chèn write barrier vào mỗi lần gán tham chiếu — nhỏ nhưng có overhead per-write.

G1, ZGC, Shenandoah (Java): Java HotSpot có nhiều GC tuỳ chọn theo tradeoff. G1 GC (Java 9+ default) chia heap thành các region nhỏ, ưu tiên thu region có nhiều rác nhất trước — giảm pause trung bình. ZGC và Shenandoah thiết kế cho pause dưới 10ms bất kể heap size, bằng cách làm phần lớn công việc concurrently.

CPython gc cycle collector: CPython duy trì một danh sách tracking tất cả object "container" (có thể chứa tham chiếu tới object khác). Cycle collector định kỳ tìm nhóm object tạo thành chu trình bằng cách tính "unreachable refcount" — refcount chỉ từ trong chu trình. Có thể tắt bằng gc.disable() nếu code không tạo chu trình, nhưng rủi ro cao.

Copying GC và compaction: một biến thể của tracing GC — thay vì mark+sweep tại chỗ, copy toàn bộ live object sang vùng bộ nhớ mới liên tiếp (eliminating fragmentation). Java Survivor space dùng copying. Ưu: sau GC, live object đặc khít, cache-friendly; cấp phát mới chỉ cần bump một pointer. Nhược: cần 2x bộ nhớ cho live set.

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

9. Tóm tắt

  • Reference counting: mỗi object có refcount; về 0 → giải phóng ngay. Deterministic, không STW pause. Không thu được chu trình tham chiếu — CPython phải thêm cycle collector riêng.
  • Tracing GC (mark-and-sweep): trace từ root, mark live, sweep rác. Thu được chu trình. Gây stop-the-world pause; cần headroom bộ nhớ.
  • Generational GC: khai thác "object chết trẻ" — GC young thường xuyên + rẻ, GC old hiếm hơn + đắt. Java HotSpot dùng Eden + Survivor + Old generation.
  • Không có GC hoàn hảo: ref-counting không thể thu cycle; tracing GC phải trả giá bằng pause và overhead.

10. Tự kiểm tra

Tự kiểm tra
Q1
Ref-counting giải phóng object khi nào? Mỗi lần gán tham chiếu có chi phí gì?
Ref-counting giải phóng object ngay lập tức khi refcount về 0 — tức là ngay khi tham chiếu cuối cùng trỏ tới object đó bị xoá (biến ra scope, gán lại, xoá khỏi container). Không cần chờ chu kỳ GC. Chi phí mỗi lần gán: runtime phải tăng refcount của object mới (refcount++) và giảm refcount của object cũ (refcount--); nếu refcount cũ về 0 thì giải phóng chuỗi. Trong CPython, mọi phép gán, truyền argument, return đều kèm hai thao tác này — overhead nhỏ nhưng hiện diện mọi lúc và khó batch. Tradeoff: trả chi phí đều theo thời gian thay vì trả gộp khi GC chạy.
Q2
Cho ví dụ cụ thể về reference cycle và giải thích vì sao ref-counting không thu được.
Chu trình điển hình: object A có trường child trỏ B; object B có trường parent trỏ lại A. Khi biến ngoài trỏ tới cả A và B bị xoá: refcount(A) giảm 1 nhưng vẫn còn 1 (vì B vẫn trỏ A); refcount(B) giảm 1 nhưng vẫn còn 1 (vì A vẫn trỏ B). Cả hai không về 0 dù không có biến nào trong code trỏ tới chúng — không thể truy cập từ bên ngoài, nhưng ref-counting không biết điều đó. Đây là reference cycle: A và B giữ nhau sống mà cả hai đều "chết" theo nghĩa ứng dụng. Tracing GC bắt được vì nó trace từ root và thấy không có đường nào từ root tới A hoặc B — cả hai unreachable, cả hai bị sweep.
Q3
Mô tả từng bước của mark-and-sweep. Vì sao phải stop-the-world trong bước mark?
Mark phase: GC bắt đầu từ root set (stack, static, thanh ghi), duyệt đồ thị tham chiếu theo BFS/DFS, đánh dấu mọi object đến được là "live". Sweep phase: GC quét toàn bộ heap tuyến tính; object không có dấu "live" là rác, trả về allocator. Vì sao stop-the-world: nếu ứng dụng tiếp tục chạy trong khi GC đang mark, một thread ứng dụng có thể tạo tham chiếu mới tới object chưa được mark (làm object đó bị sweep nhầm — use-after-free trong GC-managed memory), hoặc xoá tham chiếu tới object đã mark (GC giữ object không cần thiết). Để consistency, GC đơn giản nhất dừng tất cả thread ứng dụng. GC hiện đại dùng tri-color marking + write barrier để giảm thời gian STW, nhưng không thể loại bỏ hoàn toàn.
Q4
Generational hypothesis là gì? Vì sao nó cho phép minor GC chạy nhanh hơn full GC nhiều lần?
Generational hypothesis: phần lớn object có lifetime rất ngắn — chết ngay sau khi tạo ra (object tạm trong hàm, intermediate value, request object). Một số nhỏ object sống lâu (cache, singleton). Minor GC chỉ quét young generation (Eden + Survivor) — vùng nhỏ, chứa object mới tạo. Vì hầu hết object trong young gen đã chết theo hypothesis, số live object cần copy/retain rất ít → nhanh. Full GC quét toàn bộ heap bao gồm old gen (lớn hơn nhiều, nhiều live object hơn) → chậm hơn nhiều. Kết quả thực tế: minor GC thường dưới 10ms, full GC có thể hàng trăm ms. Với server-side Java, minor GC xảy ra vài lần mỗi giây trong khi full GC vài phút mới xảy ra một lần.
Q5
So sánh ref-counting và tracing GC về khả năng xử lý chu trình, pause, và chi phí per-assignment.
Chu trình: ref-counting không thu được cycle (refcount của các object trong chu trình mãi không về 0); tracing GC thu được vì trace từ root — object không reachable từ root bị sweep dù tham chiếu lẫn nhau. Pause: ref-counting không có stop-the-world pause lớn (giải phóng tức thì, phân bổ đều theo thời gian); tracing GC cần STW pause để đảm bảo consistency trong mark phase — từ ms đến hàng trăm ms tuỳ GC và heap size. Chi phí per-assignment: ref-counting tốn refcount++/refcount-- mỗi gán — overhead nhỏ nhưng hiện diện mọi lúc; tracing GC không có per-assignment overhead (chỉ write barrier nếu concurrent, nhỏ hơn nhiều so với inc/dec + check 0). Mỗi cách phù hợp ngữ cảnh khác nhau.
Q6
Vì sao GC tracing cần headroom bộ nhớ (thường 2-3x live set)? Nếu heap gần đầy thì GC hoạt động thế nào?
Tracing GC (đặc biệt copying GC) cần bộ nhớ trống để copy live object sang vùng mới trong khi vùng cũ chưa được giải phóng — tức là lúc đang copy cần đủ chỗ cho cả bản cũ lẫn bản mới. Ngoài ra, GC thường không chạy khi heap còn nhiều chỗ trống mà chỉ trigger khi heap đến ngưỡng (vd. 75% đầy) — nên luôn cần headroom để GC có không gian làm việc. Nếu heap gần đầy (allocation pressure cao): GC trigger thường xuyên hơn, mỗi lần pause dài hơn (nhiều rác cần sweep). Trường hợp cực đoan (GC thrashing): hầu hết thời gian CPU dành cho GC thay vì ứng dụng — throughput sụp. Giải pháp: tăng heap size (cho GC thêm headroom), giảm allocation rate (tái dùng object, pool), hoặc dùng GC tuned cho high-allocation (G1 với region-based collection).

Bài tiếp theo: Vì sao vẫn rò bộ nhớ dù có GC

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