Trong runtime managed (JVM, Go, V8/Node, Python, .NET…), bạn
new / allocate object trên
heap — thường
không gọi free tay. Ai thu hồi bộ nhớ object
không còn dùng?
Garbage collector (GC).
GC không phải “magic làm app không bao giờ hết RAM”. Nó chỉ tái sử dụng object mà chương trình không còn với tới được. Leak logic (còn reference trong map/cache/closure) thì GC cũng chịu.
Demo mô phỏng allocate → mất root → GC quét: GC mini heap. Nối VSS / RSS / USS: GC chạy trong process — số RSS bạn thấy là kết quả allocate + GC + native, không phải “heap Java = RSS”.
Bài toán một dòng
allocate nhiều → heap phình
một số object không còn ai trỏ tới (= garbage)
GC tìm garbage → thu hồi → chỗ trống cho allocate sau
// trong lúc làm việc đó, đôi khi phải pause app (STW)
Hai câu hỏi GC luôn trả lời:
- Cái gì còn sống? (reachability từ roots)
- Khi nào / bao nhiêu công sức dọn? (throughput vs latency pause)
Sống = còn “chạm” được từ root
Roots: stack frame (biến local), thanh ghi, global / static, đôi khi handles JNI/native… Từ root đi theo con trỏ field → object graph. Object không reachable từ root = garbage, kể cả còn trỏ lẫn nhau thành chu trình (cycle) — trừ model chỉ đếm refcount thuần (Python cần thêm cycle GC).
root ──► A ──► B
│
└──► C // A,B,C sống
root ──► A
X── B ──► C // B,C không reachable → garbage
// (dù B.ref = C, C.ref = B)
“Xóa biến” / ra khỏi scope / map.delete / đổi
reference — không free ngay; chỉ
bỏ root/path. GC lần sau mới hốt (hoặc refcount về 0 thì
free sớm hơn, tùy runtime).
Vòng đời thô của một collector
- Mark — đánh dấu object reachable (hoặc copy surviving sang chỗ mới — copying GC).
- Sweep / compact — vùng không mark thành free; đôi khi dồn object sống lại cho đỡ phân mảnh.
- Allocate tiếp từ free list / TLAB / bump pointer.
Biến thể: concurrent mark (chạy song song mutator), incremental, generational…
Generational — “chết trẻ”
Quan sát thực dụng: phần lớn object sống rất ngắn (request scope, temporary). GC thế hệ:
- Young / nursery — allocate nóng, minor GC thường xuyên, rẻ hơn nếu hầu hết chết sớm.
- Old / tenured — object sống sót nhiều lần được thăng hạng; major/full GC đắt hơn.
JVM HotSpot (generational), .NET, V8 có ý tương tự theo thời gian. Go historically non-generational non-compacting concurrent mark-sweep (đang/đã có cải tiến qua các version) — mental model “young/old” JVM không map 1-1.
Stop-the-world (STW)
Có đoạn GC cần dừng mutator (code app): snapshot stack, đánh dấu an toàn, compact… Thời gian đó = pause. p99 latency API / frame game / tail request hay dính đây.
- Collector “concurrent” vẫn thường còn STW ngắn (root scan, remark…), không phải zero pause.
-
Throughput GC (đốt CPU background) vs latency (pause) là trade-off
cấu hình (JVM: G1/ZGC/Shenandoah…; Go:
GOGC,GOMEMLIMIT).
Runtime khác nhau (bản đồ ngắn)
-
JVM: nhiều GC (G1 phổ biến server). Heap rõ
young/old; tool: GC log, JFR,
jstat. OOM ≠ “hết RAM máy” — hết heap Java (hoặc native riêng). -
Go: concurrent GC, pacer theo heap growth;
GOGC(mặc định 100 ≈ double heap mới GC);GOMEMLIMITsoft limit. Escape analysis: nhiều thứ “new” ra stack, không vào heap. -
V8 / Node: generational + các phase; heap
limit; native addon / Buffer ngoài V8 heap.
process.memoryUsage()—heapUsed≠ RSS. - Python (CPython): chủ yếu reference count + cyclic GC cho chu trình. Gì còn trong list/dict/closure thì không về 0.
- Rust / C: không GC runtime mặc định — ownership / free tay. (Có thể nhúng GC trong engine, nhưng không phải default app model.)
Khi “GC” là triệu chứng, không phải bệnh
- Allocate điên cuồng — parse JSON lớn mỗi request, string concat trong loop, clone object đồ sộ → GC chạy nhiều / heap phình. Tối ưu allocation > “tăng heap mù”.
-
Leak reference — cache unbounded,
Maptheo id không xóa, listener không off, static list — object vẫn reachable → GC không hốt. Gần unbounded in-flight / map giữ promise. -
Finalizer / cleaner /
__del__— trì hoãn reclaim, khó đoán; đừng dựa vào để đóng file/socket (dùng try/finally,Defer, scope). - Huge heap + full GC — pause dài; scale-out / giảm live set / GC algorithm phù hợp latency.
Nhìn metric gì
- Heap used / live set / allocation rate
- GC pause (p50/p99), tần suất minor vs major
- RSS / cgroup memory — gồm heap + native + stack + mapped file ( RSS ≠ heap)
-
Go:
runtime.MemStats,GODEBUG=gctrace=1; JVM: GC logs; Node:--expose-gcchỉ để debug, không production habit
Checklist thực dụng
- Trước khi “tăng heap gấp đôi”: đo allocation rate + object giữ lâu (heap dump / pprof).
- Cache: max size + TTL / weak ref nếu semantics cho phép.
- Pool object (buffer reuse) khi profile chỉ ra hot allocate — đừng premature.
- Latency-sensitive: chọn GC/runtime flag theo SLA; test load có đo pause, không chỉ throughput trung bình.
Một câu để nhớ
GC thu object không còn reachable — không thu “object bạn quên nhưng vẫn nhét trong map”.
Nó đổi free tay lấy pause + throughput + quy tắc reachability. Hiểu root, live set, và allocation — debug “memory” mới đúng chỗ: leak reference, allocate quá tay, hay thật sự cần chỉnh GC/limit.