Bộ nhớ/Cấp phát thủ công vs garbage collection — ai dọn rác
22/26
Bài 22 / 26~18 phútQuản lý bộ nhớ ngôn ngữ bậc caoMiễn phí lượt xem

Cấp phát thủ công vs garbage collection — ai dọn rác

malloc/free đặt trách nhiệm lên lập trình viên; GC tự thu qua reachability. So sánh an toàn, chi phí, tradeoff, và các bug điển hình của mỗi phía.

TL;DR: Trong C, mỗi malloc đi kèm một trách nhiệm: gọi free đúng chỗ đúng lúc. Quên free là memory leak; free hai lần là undefined behavior có thể sập process. Trong Java, Go, Python, runtime có một garbage collector (GC) tự tìm và thu hồi bộ nhớ "không còn ai truy cập được" — lập trình viên không cần gọi free, và cả lớp bug double-free/use-after-free biến mất. Chìa khoá là khái niệm reachability: object còn truy cập được từ root (biến trên stack, biến static) thì "sống"; không reachable là "rác" — GC thu. Tradeoff: thủ công cho chi phí runtime thấp và kiểm soát chính xác; GC cho an toàn và code đơn giản hơn, nhưng GC pause và overhead là cái giá phải trả.

Hai đồng nghiệp nhận cùng một task: viết hàm đọc file và trả về nội dung. Một người viết C, một người viết Java. Người viết C phải nghĩ thêm: bộ nhớ cấp phát cho buffer được giải phóng chưa, và giải phóng ở path nào nếu có nhiều điểm return? Người viết Java không cần nghĩ — runtime lo. Nhưng đổi lại, đôi khi chương trình Java dừng đột ngột vài chục millisecond để GC dọn dẹp. Ai dọn rác, và cái giá là gì?

1. Analogy — căn hộ tự dọn vs dịch vụ dọn phòng

Hình dung thuê căn hộ. Trong chế độ tự dọn (C), bạn mang đồ vào thì bạn phải tự mang ra — dịch vụ không can thiệp. Quên mang ra, đồ chất đống (memory leak). Mang cùng một thứ ra hai lần, nhân viên toà nhà nhầm và dọn nhầm phòng khác (double-free, undefined behavior). Trong chế độ dịch vụ dọn phòng (GC), nhân viên định kỳ đi qua, nhìn xem thứ gì không ai nhắc đến nữa rồi mang đi. Bạn không cần nhớ dọn — nhưng đôi khi nhân viên vào đúng lúc bạn đang làm việc (GC pause).

Căn hộQuản lý bộ nhớ
Bạn mang đồ vàomalloc / new cấp phát
Bạn tự mang đồ rafree thủ công
Quên mang raMemory leak
Mang ra đồ đã mang ra rồiDouble-free
Dùng đồ sau khi đã mang raUse-after-free
Nhân viên dọn phòng định kỳGarbage collector
Nhân viên nhìn xem ai còn dùng đồGC trace reachability
Đồ không ai nhắc nữaUnreachable object — rác
Cách nhớ

GC không biết bạn muốn dùng object nữa không — nó chỉ biết object đó còn truy cập được (reachable) từ code đang chạy không. Nếu không còn truy cập được, GC coi là rác và thu. Đây là điểm khác biệt căn bản với tự dọn.

2. Cấp phát thủ công — C và trách nhiệm lập trình viên

Trong C, malloc(n) cấp phát n byte trên heap và trả về con trỏ. free(ptr) trả bộ nhớ đó về cho hệ thống. Hai lệnh này phải cân bằng: mỗi malloc đúng một free.

// Correct: cap phat va giai phong can bang
char *buf = malloc(256);
if (buf == NULL) return -1;   // kiem tra that bai
// ... dung buf ...
free(buf);
buf = NULL;                   // tranh dangling pointer

Bốn bug phổ biến khi quản lý thủ công sai:

Memory leak — quên free:

// SAI: leak -- buf khong bao gio duoc free neu early return
char *buf = malloc(256);
if (parse_header(buf) < 0) {
    return -1;                // ❌ SAI: buf bi leak o day
}
free(buf);

Mỗi lần hàm đi theo nhánh return -1, 256 byte bị mất. Trong server chạy lâu dài, leak nhỏ tích luỹ thành vấn đề nghiêm trọng.

Double-free — giải phóng hai lần:

free(buf);
// ... code khac ...
free(buf);   // ❌ SAI: undefined behavior, co the corrupt heap allocator

Heap allocator duy trì metadata về block đã cấp phát. free hai lần làm hỏng cấu trúc nội bộ đó, thường dẫn tới crash hoặc lỗ hổng bảo mật.

Use-after-free — dùng sau khi đã giải phóng:

free(buf);
printf("%s\n", buf);   // ❌ SAI: buf la dangling pointer -- gia tri khong xac dinh

Con trỏ buf vẫn giữ địa chỉ cũ, nhưng bộ nhớ đó đã được trả về heap allocator và có thể đã cấp phát cho mục đích khác. Đọc/ghi vào đó là undefined behavior.

Dangling pointer — con trỏ trỏ vào vùng không còn hợp lệ:

int *p;
{
    int x = 42;
    p = &x;
}                    // x het scope, giai phong tren stack
printf("%d\n", *p);  // ❌ SAI: p la dangling pointer

Ưu điểm của manual memory: không có overhead GC, lập trình viên kiểm soát chính xác khi nàoở đâu bộ nhớ được giải phóng — quan trọng cho real-time system và embedded.

3. Garbage collection — runtime tự thu qua reachability

Garbage collector không theo dõi từng lần malloc/free. Thay vào đó, nó định nghĩa một tập root — tất cả object mà chương trình đang chạy chắc chắn còn cần:

  • Biến cục bộ trên stack của tất cả thread đang chạy.
  • Biến static và global.
  • Thanh ghi CPU.

Từ root, GC trace (duyệt) toàn bộ đồ thị object theo các tham chiếu. Object nào truy cập được từ root (trực tiếp hoặc qua chuỗi tham chiếu) là reachable — còn sống. Object nào không truy cập được từ bất kỳ root nào là unreachable — rác, GC thu.

flowchart TD
  subgraph roots["Root set (stack + static)"]
    R1["bien list"]
    R2["bien config"]
  end
  R1 --> A["ArrayList"]
  A --> B["Node B (reachable)"]
  A --> C["Node C (reachable)"]
  B --> D["Node D (reachable)"]
  R2 --> E["Config (reachable)"]
  F["Node F (unreachable)"] -. "khong co root nao tro toi" .-> F
  G["Node G (unreachable)"] -. "khong co root nao tro toi" .-> G

  style F fill:#fee2e2,stroke:#ef4444
  style G fill:#fee2e2,stroke:#ef4444

Object FG không có đường nào từ root tới — GC sẽ thu.

Code Java tương đương với ví dụ C ở trên — không cần free:

// Java: khong can free -- GC tu thu khi buf het scope
public int parseHeader(byte[] data) {
    byte[] buf = new byte[256];   // cap phat tren heap
    if (parse(buf, data) < 0) {
        return -1;                // OK: GC se thu buf sau khi het reachable
    }
    process(buf);
    return 0;
    // buf het scope -- unreachable -- GC se thu sau
}

Lập trình viên không cần nhớ free ở mỗi nhánh return. Khi buf ra khỏi scope (và không có tham chiếu nào khác giữ nó), GC sẽ thu.

Double-free và use-after-free không thể xảy ra trong Java vì GC chỉ thu object khi không còn tham chiếu nào — nếu bạn còn một biến giữ tham chiếu, object không bị thu; nếu object đã bị thu, bạn không còn tham chiếu hợp lệ nào tới nó.

4. Bảng tradeoff

Tiêu chíManual (C)Garbage Collection
An toànThấp — double-free, use-after-free, leakCao — GC loại bỏ cả lớp bug đó
Throughput CPUCao — không overhead GCThấp hơn — GC tốn CPU định kỳ
Độ trễ / pauseKhông có GC pauseCó GC pause (ms đến hàng trăm ms)
Kiểm soát thời điểmChính xác — free ngay khi muốnMất kiểm soát — GC quyết định khi nào
Độ phức tạp codeCao — phải quản lý lifetimeThấp hơn — GC lo lifetime
Memory overheadThấp — chỉ tốn metadata allocatorCao hơn — GC cần headroom bộ nhớ

5. Pitfall tổng hợp

Pitfall 1 — Nghĩ GC nghĩa là khỏi lo bộ nhớ. GC thu object unreachable. Nếu bạn vô tình giữ tham chiếu tới object không còn cần — ví dụ một static list gom toàn bộ event, một cache không có giới hạn size — object đó reachable, GC không thu. Đây là logical leak: leak không phải vì quên free, mà vì giữ tham chiếu quá lâu. Chi tiết ở bài 03 — Vì sao vẫn rò bộ nhớ dù có GC.

Pitfall 2 — Tưởng GC pause không đáng kể. Với Java Stop-The-World GC cũ, pause vài trăm millisecond xảy ra trong production là thực tế phổ biến. Hệ thống trading, game engine, real-time control — GC pause không chấp nhận được. Bài 02 — Ref-counting vs tracing GC giải thích vì sao pause xảy ra và cách giảm thiểu.

Pitfall 3 — Nhầm manual memory trong C với "kiểm soát hoàn hảo". Manual memory đúng là cho kiểm soát, nhưng trả giá bằng bug tinh vi khó tìm. Use-after-free thường không crash ngay mà âm thầm đọc/ghi sai dữ liệu, chỉ phát hiện khi heap corrupt theo cách không liên quan. Công cụ như Valgrind, AddressSanitizer tồn tại chính vì lý do này.

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

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

RAII và smart pointer (C++): C++ thêm lên C một cơ chế tự động gọi destructor khi object ra khỏi scope — gọi là RAII (Resource Acquisition Is Initialization). Smart pointer như unique_ptr<T> (ownership độc quyền) và shared_ptr<T> (ownership chia sẻ qua ref-counting) gần như loại bỏ manual free trong C++ hiện đại trong khi vẫn không có GC overhead.

Rust ownership: Rust đi xa hơn — ownership và borrow checker được tích hợp vào type system, kiểm tra tại compile time thay vì runtime. Không có GC, không có manual free, và double-free/use-after-free là compile error. Đây là điểm giữa manual và GC: an toàn của GC với chi phí của manual.

Arena allocator: cấp phát hàng loạt object vào một block lớn, giải phóng cả block một lần. Phổ biến trong game engine, compiler, web server để né chi phí của từng malloc/free riêng lẻ — throughput cao, không có GC pause, nhưng đòi hỏi thiết kế lifetime rõ ràng.

Phổ manual ↔ automatic: thực tế không phải nhị phân. C = manual hoàn toàn; C++ smart pointer = semi-automatic; Python/Java/Go = GC tự động; Rust = static ownership. Mỗi điểm trên phổ có tradeoff khác nhau về an toàn, chi phí, và ergonomics.

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

8. Tóm tắt

  • Manual memory (C): lập trình viên gọi malloc/free; cân bằng không đúng → leak, double-free, use-after-free. Ưu: chi phí thấp, kiểm soát chính xác.
  • Garbage collection: runtime tự thu object unreachable — không còn tham chiếu từ root nào. Ưu: loại bỏ cả lớp bug; nhược: GC pause, overhead CPU và bộ nhớ.
  • Root set: biến trên stack, biến static/global, thanh ghi — điểm khởi đầu để GC trace reachability.
  • Reachable = sống; unreachable = rác — GC không quan tâm bạn "muốn" giải phóng hay không, chỉ quan tâm còn tham chiếu hay không.
  • GC không bảo vệ bạn khỏi logical leak — giữ tham chiếu không cần thiết thì object mãi reachable, mãi không bị thu.

9. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao trong C, hàm có nhiều điểm return khiến quản lý memory dễ bị leak?
Trong C, lập trình viên phải gọi free trước mỗi điểm return. Nếu hàm có 3 điểm return (ví dụ xử lý lỗi ở nhiều bước), mỗi điểm phải giải phóng đúng tập object đã cấp phát tới lúc đó — tập đó khác nhau ở mỗi nhánh. Quên một free ở một nhánh ít gặp (ví dụ error path) là memory leak âm thầm — không crash, không báo lỗi, chỉ ăn RAM dần. RAII (C++) giải quyết bằng destructor tự chạy khi ra scope; GC (Java) giải quyết bằng không cần free — cả hai tránh phụ thuộc vào lập trình viên nhớ đủ mọi nhánh.
Q2
Định nghĩa 'root set' trong context GC. Tại sao root set là điểm khởi đầu để xác định object nào còn sống?
Root set là tập hợp tất cả tham chiếu mà chương trình đang chạy chắc chắn còn cần truy cập: biến cục bộ trên stack của tất cả thread đang chạy, biến static/global, và thanh ghi CPU giữ tham chiếu. Root set là điểm khởi đầu vì GC không thể hỏi "bạn còn muốn object này không" — nó chỉ quan sát được graph tham chiếu. Object reachable từ root (trực tiếp hoặc qua chuỗi tham chiếu) chắc chắn có thể được truy cập bởi code đang chạy, nên không được thu. Object không reachable từ bất kỳ root nào không có cách nào được dùng nữa — an toàn để thu. Không có root set, GC không có nơi bắt đầu trace.
Q3
Double-free gây ra loại lỗi gì trong C? Vì sao GC loại bỏ được lỗi này?
Double-free (gọi free hai lần trên cùng một con trỏ) là undefined behavior trong C. Heap allocator duy trì metadata nội bộ về các block đã cấp phát (danh sách free, kích thước block). free lần đầu đặt block đó vào danh sách free và cập nhật metadata. free lần hai trên cùng địa chỉ đó corrupt metadata — heap allocator không biết block đó đã free, đặt nó vào danh sách free lần nữa. Lần cấp phát sau có thể nhận cùng block hai lần cho hai mục đích khác nhau, dẫn tới ghi đè dữ liệu lẫn nhau. GC loại bỏ được vì lập trình viên không bao giờ gọi free — GC tự quyết định khi nào thu, và chỉ thu một lần khi unreachable, không bao giờ thu cùng object hai lần.
Q4
Một object trong Java không còn được code nào dùng, nhưng bộ nhớ chưa được giải phóng ngay. Đây có phải memory leak không? Giải thích.
Không phải leak theo nghĩa C — đây là GC chưa chạy tới. GC thu object theo lịch của nó (thường khi heap gần đầy hoặc theo chu kỳ), không ngay lập tức khi object unreachable. Bộ nhớ sẽ được thu trong lần GC kế tiếp. Điều này khác với leak thật trong Java: logical leak là khi object vẫn reachable từ một tham chiếu nào đó dù bạn không còn cần — ví dụ object được thêm vào một static list mà không bao giờ bị xoá. Khi đó GC không thu vì thấy còn reachable; đây là leak thật. Bài 03 giải thích chi tiết.
Q5
So sánh chi phí runtime của manual memory (C) và GC. Khi nào GC pause là vấn đề thực tế?
Manual memory (C) có chi phí runtime thấp: mỗi free chỉ cập nhật metadata allocator, không có overhead định kỳ. GC có hai loại chi phí: (1) throughput overhead — GC tốn CPU để trace và sweep, thường vài phần trăm đến chục phần trăm; (2) GC pause — GC dừng tất cả thread ứng dụng (stop-the-world) để đảm bảo consistency khi trace, có thể từ vài ms đến hàng trăm ms với GC cũ (Serial, Parallel). Pause là vấn đề thực tế khi: hệ thống yêu cầu latency thấp ổn định (game engine nhảy frame khi GC pause, trading system bỏ lỡ window, API SLA vượt ngưỡng). GC hiện đại (G1, ZGC, Shenandoah) giảm pause xuống dưới vài ms nhưng không loại bỏ hoàn toàn.
Q6
RAII trong C++ giải quyết vấn đề quên free thế nào mà không cần GC?
RAII (Resource Acquisition Is Initialization) là kỹ thuật liên kết lifetime của resource (bộ nhớ, file, lock) với lifetime của một object C++. Khi object ra khỏi scope, destructor tự động chạy và giải phóng resource. Smart pointer unique_ptr<T> wrap con trỏ thô: khi unique_ptr ra khỏi scope (dù qua bất kỳ nhánh return hay exception nào), destructor gọi delete tự động. Lập trình viên không cần nhớ gọi free ở mỗi nhánh. Khác GC ở chỗ: RAII giải phóng ngay lập tức và xác định khi ra scope (không có pause), nhưng vẫn đòi hỏi lập trình viên thiết kế ownership rõ ràng. Rust đẩy ý tưởng này xa hơn bằng cách encode ownership vào type system và kiểm tra tại compile time.

Bài tiếp theo: Ref-counting vs tracing 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