Quản lý memory tự động thay đổi bên quyết định khi nào storage được tái sử dụng. Chương trình cấp phát object, nối chúng thành graph rồi bỏ một số liên kết. Garbage collector (GC) phải phân biệt object chương trình còn quan sát được với object không thể truy cập nữa, sau đó thu hồi nhóm thứ hai an toàn.

Application thread thay đổi graph trong lúc collector đọc nó. Di chuyển object giúp heap gọn nhưng mọi reference phải được cập nhật chính xác. Pause trung bình thấp vẫn có thể che giấu tail pause dài. Hiểu GC là hiểu các đánh đổi này.

Reachability xác định object còn sống

Phần lớn managed runtime mô hình hóa heap thành một graph có hướng. Một cạnh xuất hiện khi object này chứa reference đến object khác. Collection bắt đầu từ các root: những reference mà code đang chạy có thể dùng ngay, chẳng hạn local variable trong stack frame đang active, static hoặc global field, CPU register được mô tả bởi stack map, handle của runtime và một số reference đến từ native interop.

Một object còn sống nếu tồn tại đường đi từ bất kỳ root nào đến nó. Gọi R0 là tập root và refs(x) là tập object được x tham chiếu. Tập reachable là điểm bất động nhỏ nhất:

Rn+1=Rn{yxRnyrefs(x)}R_{n+1} = R_n \cup \{y \mid x \in R_n \land y \in refs(x)\}

Quá trình tracing dừng khi Rn+1=RnR_{n+1} = R_n. Mọi object đã cấp phát nhưng nằm ngoài tập cuối cùng đều là garbage:

garbage=heapreachablegarbage = heap - reachable
flowchart LR S[Root tren stack] --> A[Request] G[Root toan cuc] --> C[Cache] A --> B[Buffer] B --> A C --> D[Entry] X[Node da tach] --> Y[Listener] Y --> X classDef dead stroke:#ef4444,stroke-width:2px,stroke-dasharray:5 5 class X,Y dead

RequestBuffer tạo thành cycle, nhưng chúng vẫn sống vì root trên stack đi đến được chúng. Node da tachListener cũng tạo cycle, song không có đường đi từ root đến hai object này nên tracing collector có thể thu hồi cả hai. GC không hỏi object còn hữu ích theo nghiệp vụ hay không. Một cache entry bị quên vẫn reachable, vì vậy vẫn được coi là sống dù application không bao giờ chủ động đọc nó nữa.

Note

Reachability bảo thủ đối với khả năng quan sát trong tương lai. Nó chứng minh object unreachable không thể được dùng qua managed reference; nó không chứng minh mọi object reachable chắc chắn sẽ được dùng.

Tracing và reference counting giải hai bài toán khác nhau

Reference counting lưu số reference đi vào mỗi object. Thêm cạnh thì tăng counter; bỏ cạnh thì giảm. Khi counter về zero, runtime có thể hủy object ngay, rồi giải phóng các cạnh đi ra. Cơ chế này cho thời điểm reclaim dễ đoán trong Swift ARC, Python và smart pointer của C++.

Điểm mù của nó là cycle. Nếu A trỏ đến BB trỏ về A, mỗi counter vẫn dương sau khi external reference biến mất. Python bổ sung cycle detector; weak reference và ownership tường minh cũng có thể phá cycle. Counting còn thêm chi phí vào phép gán reference, đôi khi cần atomic operation giữa các thread.

Tracing đi từ root và khám phá toàn bộ live graph. Cycle không cần trường hợp đặc biệt: cycle unreachable đơn giản là không bao giờ được mark. Tracing thường gom chi phí vào các collection phase và có thể di chuyển object, đổi lại nó tạo pause hoặc cần phối hợp concurrent phức tạp.

Thuộc tính Reference counting Tracing GC
Thời điểm reclaim Thường ngay khi counter về zero Theo từng đợt collection
Cycle unreachable Cần cơ chế bổ sung Được thu hồi tự nhiên
Chi phí mutation Tăng/giảm counter khi đổi cạnh Thường là write barrier rẻ hơn
Di chuyển object Hiếm và khó thực hiện Phổ biến với compacting/copying collector
Dạng pause Chi phí phân tán; có thể cascade khi release Pause riêng cùng phần việc concurrent tùy collector
Memory phụ trợ Counter trên mỗi managed object Mark bit, region, queue hoặc forwarding data

Hai họ collector đều không thay thế ownership cho resource ngoài managed heap. File descriptor, socket, lock, GPU buffer và transaction cần lifetime xác định bằng using, defer, finally hoặc close tường minh. Finalizer chạy vào thời điểm không được đảm bảo, có thể chưa chạy khi process thoát và thường khiến object phải sống thêm ít nhất một collection cycle.

Mark, sweep, compact và copy

Collector mark-sweep mark mọi object tìm thấy từ root, rồi trả block không được mark về free list. Object giữ address ổn định nhưng allocation lâu ngày để lại các lỗ trống. Chi phí sweep có thể tỷ lệ với heap capacity thay vì live data.

Mark-compact mark object sống, trượt chúng sát lại và cập nhật reference. Nó cho phép pointer-bump allocation và cải thiện locality, nhưng di chuyển live bytes tốn kém. Runtime có thể chỉ compact region bị fragment nặng.

Copying collector copy mỗi object reachable từ source sang destination space và lưu forwarding address. Source sau đó trở thành vùng trống, còn survivor tự động liền nhau. Chi phí theo live data nhưng cần băng thông copy và destination capacity.

Collector Đơn vị reclaim Điểm mạnh chính Chi phí chính
Mark-sweep Block không được mark Address ổn định, tracing đơn giản Fragmentation và quét heap
Mark-compact Object sống được dồn lại Heap đặc, allocation nhanh Di chuyển và cập nhật reference
Copying Object reachable được copy ra Chi phí theo live bytes, không fragment Băng thông copy và destination space
Generational Thường thu nursery trước Tận dụng lifetime ngắn Barrier, remembered set và promotion tuning

Cost model hữu ích không chỉ là câu “GC tốn tài nguyên”. Nếu live_bytes sống sót qua một copying pass với băng thông memory là copy_bandwidth, cận dưới của thời gian di chuyển xấp xỉ:

copytimelivebytes/copybandwidthcopy_time \mathrel{\gtrsim} live_bytes / copy_bandwidth

Giảm allocation rate làm collection thưa hơn, còn giảm long-lived state làm major collection nhẹ hơn. Hai tối ưu này tác động đến hai phần khác nhau.

Một generational tracing collector thu nhỏ

Collector thật phải biết object layout, phối hợp thread, quét register và quản lý region. Pseudocode sau giữ các invariant chính: root khởi tạo tracing, full collection quét mọi object, minor collection quét young object từ root cùng old object đã ghi nhớ, còn write barrier ghi lại cạnh old-to-young.

text
WRITE(source, target):
    source.fields.add(target)
    if source.old and target.young:
        remembered.add(source)       // write barrier

TRACE(seeds, eligible):
    work = stack(seeds)
    while work is not empty:
        object = work.pop()
        if object is unmarked and eligible(object):
            object.marked = true
            work.pushAll(object.fields)

FULL_COLLECT(roots):
    TRACE(roots, every_object)
    reclaim every unmarked object; clear remaining marks

MINOR_COLLECT(roots):
    seeds = young references in roots
    seeds += young references from remembered old objects
    TRACE(seeds, young_objects_only)
    reclaim unmarked young objects
    age survivors; promote objects past the age threshold

Nếu thiếu barrier, minor collection có thể thu hồi young object vẫn được old object tham chiếu. Remembered set tóm tắt nơi cạnh xuyên generation bắt đầu. Runtime production thường dùng card table: một write đánh dấu dirty cho vùng address nhỏ, rồi collector quét lại object trong dirty card. Barrier phải có trên mọi đường ghi reference; bỏ sót nó là lỗi correctness.

Generation, pause và concurrent work

Generational hypothesis nói rằng phần lớn object mới cấp phát chết trẻ. String tạm, request wrapper, kết quả iterator và array trung gian thường biến mất trong vài collection đầu. Generational collector cấp phát chúng trong nursery và thu vùng nhỏ này thường xuyên. Survivor tăng age rồi được promote sang old generation, nơi được collect ít thường xuyên hơn.

Đây là tối ưu theo thống kê, không phải quy luật semantic. Object sống trung hạn có thể qua vài nursery pass rồi chết ngay sau khi promote, gây premature promotion. Nursery quá nhỏ làm collection dày; nursery quá lớn kéo dài minor pause. Threshold phải dựa trên lifetime đo được.

Collector cũng khác nhau ở cách phối hợp với application thread, thường được gọi là mutator:

  • Stop-the-world collector tạm dừng mutator trong lúc trace hoặc relocate. Thiết kế đơn giản và có thể cho throughput cao nhất với batch job.
  • Incremental collector chia công việc thành các slice ngắn, giảm từng pause nhưng trả giá bằng barrier và bookkeeping.
  • Concurrent collector mark hoặc sweep trong lúc mutator tiếp tục chạy. Nó vẫn cần synchronization pause ngắn và đủ CPU headroom để đuổi kịp allocation.
  • Mostly concurrent compacting collector có thể relocate một số region bằng forwarding pointer, read barrier hoặc evacuation pause ngắn.

Concurrency tạo ra bài toán snapshot. Giả sử collector đã quét object A; application sau đó ghi pointer từ A đến object B chưa được mark và xóa đường đi duy nhất còn lại tới B. Collector không biết mutation này có thể thu hồi nhầm một object đang sống. Barrier strategy giữ invariant cần thiết: incremental-update barrier đánh dấu reference mới được cài đặt, còn snapshot-at-the-beginning barrier ghi lại reference bị ghi đè để collector vẫn thấy logical graph tại thời điểm bắt đầu.

Warning

“Concurrent” không có nghĩa là “không pause”. Root scanning, thread handshake, xử lý remembered set và một số relocation phase vẫn dừng hoặc phối hợp mutator. Hãy đánh giá maximum cùng percentile của pause, không chỉ tên collector.

Leak, metric, tuning và các ngộ nhận

Chương trình có GC hoàn toàn có thể memory leak. Leak là retention ngoài ý muốn, không chỉ là storage unreachable chưa kịp collect. Map không giới hạn, event listener không được gỡ, timer capture closure lớn, queue backlog, accidental global và cache thiếu eviction đều duy trì đường đi từ root đến data. GC giữ chúng lại là hành vi chính xác.

Heap snapshot trả lời “object này bị giữ bởi cái gì?” qua dominator tree và retaining path. Allocation profile trả lời “memory được tạo ở đâu?” Một snapshot dễ gây hiểu nhầm vì heap khỏe mạnh vẫn tăng giữa hai collection. Hãy so sánh sau các full-GC point tương đương hoặc qua nhiều chu kỳ workload.

Các tín hiệu vận hành hữu ích gồm:

Metric Điều nó cho biết
Allocation rate Application tiêu thụ free heap nhanh đến đâu
Live set sau major GC Memory bị giữ dài hạn; baseline tăng dần gợi ý leak
Tần suất minor/major collection Áp lực chủ yếu nằm ở nursery hay old generation
Pause p50/p95/p99/max Phân phối latency nhìn thấy bởi user và stall hiếm
Phần trăm CPU cho GC Collector overhead và cạnh tranh CPU khi concurrent
Promotion rate Áp lực survivor chuyển sang old generation
Fragmentation hoặc evacuation failure Free bytes có dùng được và compaction có tiến hành được không

Một ước lượng đơn giản cho áp lực cấp phát là:

allocationheadroom=reclaimrateallocationrateallocation_headroom = reclaim_rate - allocation_rate

Nếu allocation_headroom âm trong thời gian dài, tăng heap chỉ trì hoãn failure. Nếu live set ổn định nhưng pause quá thường xuyên, nursery lớn hơn có thể hữu ích. Nếu major pause phải di chuyển quá nhiều live bytes, giảm retained state hoặc chọn concurrent collector có thể quan trọng hơn. Nếu GC CPU cao do lượng allocation ngắn hạn cực lớn, chỉ loại allocation khỏi hot path sau khi profile chứng minh nó đáng kể.

Các ngộ nhận phổ biến dẫn thẳng đến tuning sai:

  • “GC loại bỏ mọi memory bug.” Nó ngăn nhiều lỗi use-after-free và double-free, nhưng không ngăn retention leak, native-memory corruption hay resource leak.
  • “Gọi GC thủ công sẽ sửa leak.” Việc này có thể giúp đo đạc rõ hơn; nó không cắt được một reachable path.
  • “Heap lớn hơn luôn nhanh hơn.” Heap lớn có thể giảm frequency nhưng tăng footprint, công việc major cycle và worst-case pause.
  • “Mục tiêu là zero allocation.” Bump-pointer allocation có thể cực rẻ. Hãy tối ưu latency, throughput và footprint thay vì một counter mang tính nghi thức.
  • “Finalizer là destructor.” Timing và ordering của finalizer không xác định, nên correctness không được phụ thuộc vào nó.
  • “Cycle luôn là leak.” Với tracing GC, cycle unreachable được reclaim bình thường; cycle reachable chỉ là leak nếu application không còn muốn giữ nó.

Tip

Hãy tuning bằng workload lặp lại được và latency objective cụ thể. Mỗi lần chỉ đổi một control, ghi lại allocation rate, post-GC live set, GC CPU và pause percentile, rồi kiểm tra cả application throughput chứ không chỉ statistic của collector.

Điều cần nhớ

  • Reachability từ root, không phải trực giác hay việc object nằm trong cycle, xác định liveness của tracing collector.
  • Reference counting reclaim nhanh khi counter về zero nhưng cần hỗ trợ cho cycle; tracing tự nhiên thu hồi cycle unreachable.
  • Mark-sweep giữ address ổn định, compaction cải thiện density, copying tính chi phí theo survivor, còn generation khai thác lifetime pattern phổ biến.
  • Write barrier là cơ chế correctness cho phép collector bỏ qua hoặc đọc concurrently một phần graph đang thay đổi.
  • Concurrent collector giảm pause nhưng không xóa synchronization, CPU cost hay memory overhead.
  • Managed memory vẫn leak qua reachable path ngoài ý muốn; retaining path và xu hướng live set sau collection giúp tìm nguyên nhân.
  • GC tuning chính là workload tuning. Hãy đo allocation, survival, promotion, pause và collector CPU trước khi đổi heap size hoặc thuật toán.