Mục lục
Chúng ta sẽ làm gì?
Các lập trình viên C++ không bao giờ viết free(pointer). Đó không phải là một tính năng bị thiếu — đó chính là mục đích. Mỗi đối tượng bạn tạo trong ứng dụng .NET đều được tự động theo dõi và cuối cùng thu hồi bởi một hệ thống con thời gian chạy gọi là Garbage Collector (GC). Hiểu cách GC hoạt động là nền tảng để viết mã .NET vừa nhanh vừa hiệu quả về bộ nhớ.
GC là một thành phần của Common Language Runtime (CLR) chạy song song với ứng dụng của bạn. Nhiệm vụ của nó là giám sát những đối tượng trên heap vẫn còn reachable từ mã đang chạy và thu hồi bộ nhớ bị chiếm bởi những đối tượng không còn reachable. Trước khi xem xét GC làm gì, bạn cần hiểu hai vùng bộ nhớ mà nó hoạt động: stack và heap.
Bài viết này đề cập đến những kiến thức cơ bản mà mọi lập trình viên .NET nên biết: cách CLR chia bộ nhớ giữa stack và heap, cách các kiểu giá trị và kiểu tham chiếu behave khác nhau khi gán, cách thuật toán ba pha mark-sweep-compact của GC tìm và thu hồi các đối tượng unreachable, bộ nhớ fragment là gì và tại sao nó làm giảm hiệu suất, và bộ nhớ ảo cho mỗi process một vùng địa chỉ isolated.
Tại sao chúng ta cần làm?
Mỗi lần cấp phát bộ nhớ là một hợp đồng: cái gì đó cấp phát bộ nhớ, và cái gì đó phải cuối cùng giải phóng nó. Trong các ngôn ngữ không có GC — như C hoặc C++ — hợp đồng đó hoàn toàn thuộc về lập trình viên. Quên giải phóng một lần cấp phát thì process sẽ tiêu thụ bộ nhớ vô thời hạn. Giải phóng một lần cấp phát quá sớm và mã đọc tiếp theo sẽ đọc dữ liệu bị hỏng hoặc crash. Giải phóng cùng một block hai lần thì bookkeeping nội bộ của bộ allocater bị hỏng, tạo ra lỗi gần như không thể tái tạo.
Hai loại lỗi này — memory leak và lỗi use-after-free — là những lỗi đắt đỏ nhất trong phần mềm production. Chúng xuất hiện dưới dạng sự tăng bộ nhớ từ từ chỉ thấy được dưới tải liên tục, hoặc các crash ngẫu nhiên trong các đường dẫn mã hiếm khi thực thi. Toàn bộ hệ sinh thái công cụ (Valgrind, AddressSanitizer) tồn tại chỉ vì những lỗi này rất khó tìm sau sự cố.
Ngoài tính đúng đắn, bố cục vật lý của các đối tượng trong bộ nhớ ảnh hưởng đến hiệu suất. Khi các đối tượng được tạo và giải phóng trong suốt quá trình chạy, heap phát triển các khoảng trống — các vùng đã giải phóng rải rác giữa các đối tượng sống. Một lần cấp phát mới không thể vừa vào bất kỳ khoảng trống hiện có nào buộc heap phải mở rộng, tiêu thụ nhiều bộ nhớ ảo hơn cần thiết. Sự phân mảnh bộ nhớ này worsens theo thời gian, tăng cả bộ nhớ resident footprint của process và thời gian tìm kiếm một block trống phù hợp.
GC của CLR loại bỏ toàn bộ loại lỗi giải phóng thủ công và quản lý phân mảnh thông qua compaction. Các lập trình viên hiểu mô hình cơ sở viết mã tạo ra ít GC pressure hơn, tránh cấp phát không cần thiết, và hợp tác với thời gian chạy thay vì chống lại nó.
Chúng ta sẽ thực hiện như thế nào?
Stack: Cố định kích thước và nhanh
Stack là một vùng bộ nhớ liền mạch hoạt động chính xác như cấu trúc dữ liệu cùng tên: last in, first out. Khi một phương thức được gọi, thời gian chạy đẩy một stack frame lên trên nó chứa các biến cục bộ và tham số của phương thức. Khi phương thức trả về, frame đó được pop ra trong một thao tác duy nhất — giải phóng tất cả bộ nhớ của nó ngay lập tức.
Stack hoạt động hiệu quả như vậy vì mỗi mục nó lưu trữ có kích thước đã biết, cố định tại thời điểm biên dịch. Các kiểu giá trị nguyên thủy như int (4 byte), long (8 byte), double (8 byte), và bool (1 byte), cùng với các kiểu struct tùy chỉnh, đủ điều kiện — kích thước của struct luôn cố định tại thời điểm biên dịch bất kể các trường của nó. Vì trình biên dịch biết chính xác mỗi kiểu cần bao nhiêu không gian, thời gian chạy cấp phát và thu hồi chúng chỉ với phép toán con trỏ — không tìm kiếm, không bookkeeping, không liên quan GC. Lưu ý rằng nếu struct chứa một trường kiểu tham chiếu, tham chiếu (con trỏ) sống trên stack trong khi đối tượng được tham chiếu vẫn sống trên heap.
// Cả ba biến đều sống trên stack.
// Kích thước của chúng cố định tại thời điểm biên dịch.
int activeConnections = 0;
double averageLatencyMs = 14.7;
bool isCircuitOpen = false;
Heap: Động và được quản lý bởi GC
Heap là một pool bộ nhớ mở rộng và thu nhỏ động trong thời gian chạy. Nó lưu trữ các đối tượng có kích thước không thể xác định tại thời điểm biên dịch — chủ yếu là các实例 của kiểu class, mảng và chuỗi. Ví dụ, một chuỗi có thể chứa một ký tự duy nhất hoặc toàn bộ nội dung của tệp cấu hình. Vì kích thước của nó không biết cho đến khi thực thi, nó không thể sống trên stack.
Khi bạn viết new SomeClass(), CLR cắt ra một block liền mạch trên heap có kích thước chứa các trường của đối tượng đó, viết header đối tượng, và trả về một tham chiếu cho người gọi. GC theo dõi mỗi lần cấp phát như vậy. Định kỳ — được kích hoạt bởi áp lực cấp phát và các ngưỡng có thể cấu hình — nó xác định các đối tượng không còn reachable từ bất kỳ tham chiếu sống nào và thu hồi không gian của chúng.
Kiểu giá trị: Sao chép khi gán
Khi bạn gán một biến kiểu giá trị sang biến khác, thời gian chạy sao chép toàn bộ giá trị tại thời điểm đó. Hai biến sau đó giữ dữ liệu độc lập — thay đổi một biến không ảnh hưởng đến biến kia.
int requestsHandled = 250;
int checkpoint = requestsHandled; // checkpoint nhận bản sao của 250
requestsHandled = 0; // đặt lại bộ đếm trực tiếp
Console.WriteLine(checkpoint); // 250
// checkpoint không bị ảnh hưởng. Nó giữ bản sao độc lập của riêng nó.
Trạng thái stack sau ba câu lệnh này cho thấy hai slot hoàn toàn riêng biệt — không chia sẻ dữ liệu với nhau:
Stack
────────────────────────────
requestsHandled │ 0
checkpoint │ 250
────────────────────────────
Hai slot độc lập.
Kiểu tham chiếu: Sao chép tham chiếu, không phải đối tượng
Kiểu tham chiếu behave khác. Khi bạn gán một biến kiểu tham chiếu sang biến khác, thời gian chạy sao chép tham chiếu — địa chỉ heap — không phải bản thân đối tượng. Cả hai biến sau đó trỏ cùng một đối tượng.
public class Invoice
{
public decimal Amount { get; set; }
}
var invoice = new Invoice { Amount = 500m };
var copy = invoice; // copy giữ cùng địa chỉ heap với invoice
invoice.Amount = 0m; // sửa đổi thông qua invoice...
Console.WriteLine(copy.Amount); // 0
// ...được hiển thị thông qua copy vì chỉ có một đối tượng Invoice tồn tại trên heap.
Stack Heap
────────────────────── ─────────────────────────────────
invoice ──────────────────────► Invoice
copy ──────────────────────► ┌─────────────────────────────┐
│ Amount = 0 │
└─────────────────────────────┘
Cả hai biến đều trỏ cùng một đối tượng heap.
"var copy = invoice" đã sao chép địa chỉ, không phải bản thân Invoice.
Hành vi tương tự áp dụng khi truyền các instance class vào phương thức. Phương thức nhận cùng tham chiếu heap mà người gọi giữ, nên các thay đổi bên trong phương thức được hiển thị cho người gọi. Truyền một struct, ngược lại, cho phương thức bản sao độc lập riêng — bản gốc của người gọi không bị ảnh hưởng.
GC thu gom như thế nào: Mark, Sweep và Compact
Mỗi lần thu gom GC chạy một thuật toán ba pha. Hiểu từng pha giải thích tại sao xảy ra tạm dừng và thời gian chạy đang làm gì trong thời gian đó.
Pha 1 — Mark: tìm những gì còn sống
GC bắt đầu từ một tập hợp cố định các GC roots — các điểm vào đảm bảo đang sống. Roots bao gồm các biến cục bộ trên tất cả các thread stack đang hoạt động, các trường static, các register CPU giữ tham chiếu, và các mục trong hàng đợi finalization. Bắt đầu từ mỗi root, GC duyệt qua mỗi tham chiếu đệ quy và đánh dấu mỗi đối tượng reachable. Bất kỳ đối tượng nào không reachable từ root vào cuối quá trình duyệt đều đã chết.
Pha 1 — Mark: truy vết tất cả đối tượng reachable từ GC roots
GC Roots
(stack locals, static fields, GC handles)
│
├──► [A] ──► [C] ──► [E] ← reachable → đánh dấu ✓
│
└──► [B] ──► [D] ← reachable → đánh dấu ✓
[F] [G] ← không có đường dẫn root → KHÔNG đánh dấu ✗
Pha 2 — Sweep: thu hồi đối tượng đã chết
Khi việc đánh dấu hoàn thành, mọi đối tượng chưa được đánh dấu đều đã chết. GC thu hồi các vùng bộ nhớ đó, làm cho chúng có sẵn cho các cấp phát trong tương lai. Các đối tượng chưa được di chuyển — heap bây giờ có các khoảng trống nơi các đối tượng chết nằm.
Pha 2 — Sweep: bộ nhớ của đối tượng chết được thu hồi
Trước:
┌────┬────┬────┬────┬────┬────┐
│ A✓ │ F✗ │ B✓ │ G✗ │ C✓ │ E✓ │
└────┴────┴────┴────┴────┴────┘
Sau:
┌────┬────┬────┬────┬────┬────┐
│ A │FREE│ B │FREE│ C │ E │
└────┴────┴────┴────┴────┴────┘
Các đối tượng sống vẫn ở nguyên vị trí. Các khoảng trống xuất hiện nơi F và G nằm.
Pha 3 — Compact: đóng các khoảng trống và cập nhật tham chiếu
Các khoảng trống tự do rải rác gây ra phân mảnh bộ nhớ — hai khoảng trống 40 KB không thể đáp ứng một lần cấp phát 60 KB. Compaction giải quyết vấn đề này bằng cách trượt tất cả các đối tượng tồn tại về phía cuối thấp của heap. Mọi tham chiếu trỏ đến một đối tượng đã di chuyển được cập nhật sang địa chỉ mới. Kết quả là một block trống liền mạch duy nhất.
Pha 3 — Compact: trượt các survivor lại với nhau; cập nhật tất cả tham chiếu
Trước:
┌────┬────┬────┬────┬────┬────┐
│ A │FREE│ B │FREE│ C │ E │
└────┴────┴────┴────┴────┴────┘
Sau:
┌────┬────┬────┬────┬──────────┐
│ A │ B │ C │ E │ FREE │
└────┴────┴────┴────┴──────────┘
▲
con trỏ cấp phát tiếp theo
Tất cả tham chiếu đến B, C, và E được cập nhật sang địa chỉ mới.
Compaction yêu cầu tạm thời tạm dừng tất cả các thread ứng dụng — một暂停 Stop-the-World — để không có thread nào đọc tham chiếu cũ trong khi các đối tượng đang di chuyển. GC giảm thiểu thời gian暂停 thông qua mô hình generational: các đối tượng được thăng hạng thông qua Gen 0, Gen 1 và Gen 2 dựa trên thời gian tồn tại, và Gen 0 được thu gom thường xuyên hơn nhiều vì hầu hết các cấp phát đều ngắn hạn. Large Object Heap (LOH) — cho các đối tượng bằng hoặc trên 85.000 byte — mặc định bỏ qua compaction; nó có thể được compact theo yêu cầu bằng cách đặt GCSettings.LargeObjectHeapCompactionMode trước khi kích hoạt một lần thu gom đầy đủ.
Phân mảnh bộ nhớ: Một ví dụ cụ thể
Khi mã chạy và các đối tượng được thu gom, heap tích lũy các khoảng trống — các vùng đã giải phóng rải rác giữa các đối tượng tồn tại. Một heap bị phân mảnh trông như thế này:
Heap sau nhiều lần cấp phát và thu gom:
Offset: 0 100 200 300 400 500 KB
┌────────┬────────┬────────┬────────┬────────┐
│ A │ FREE │ B │ FREE │ C │
│ 100 KB │ 100 KB │ 100 KB │ 100 KB │ 100 KB │
└────────┴────────┴────────┴────────┴────────┘
Tổng tự do : 200 KB | Block liền mạch lớn nhất: 100 KB
Một lần cấp phát 150 KB mới thất bại — không có khoảng trống đơn lẻ nào đủ lớn.
Sau pha compact, các khoảng trống được đóng lại và một block trống liền mạch duy nhất được khôi phục:
Sau khi GC compact:
Offset: 0 100 200 300 400 500 KB
┌────────┬────────┬────────┬──────────────────────┐
│ A │ B │ C │ FREE │
│ 100 KB │ 100 KB │ 100 KB │ 200 KB │
└────────┴────────┴────────┴──────────────────────┘
Lần cấp phát 150 KB bây giờ thành công trong block trống thống nhất.
Bộ nhớ ảo: Mỗi process có không gian riêng
Bộ nhớ .NET cấp phát không phải RAM vật lý thô. Hệ điều hành trình bày mỗi process đang chạy với một vùng địa chỉ ảo riêng — một dãy địa chỉ mà process tin rằng nó sở hữu độc quyền. Kernel OS ánh xạ các địa chỉ ảo sang các frame bộ nhớ vật lý phía sau hậu trường, thực thi sự cô lập nghiêm ngặt: một process không thể đọc hoặc ghi vào vùng ảo của process khác.
RAM vật lý
┌─────────────────────────────────────────────────────────────────┐
│ │
│ Quy trình API .NET của bạn │
│ Không gian ảo: 0x0000_0000 - 0xFFFF_FFFF │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Stack │ Managed Heap │ JIT Code │ Metadata │ ... │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ Quy trình SQL Server của bạn │
│ Không gian ảo: 0x0000_0000 - 0xFFFF_FFFF │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Stack │ Managed Heap │ JIT Code │ Metadata │ ... │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Mỗi process thấy địa chỉ riêng bắt đầu từ zero.
OS ánh xạ các trang ảo sang các frame vật lý một cách độc lập.
Đối với các lập trình viên .NET điều này có hậu quả thực tiễn trực tiếp: managed heap là một vùng được CLR kiểm soát trong vùng địa chỉ ảo của process. Các công cụ chẩn đoán như dotnet-counters và PerfView báo cáo áp lực bộ nhớ theo vùng này — không phải tổng RAM vật lý của máy. Một process có thể cam kết nhiều bộ nhớ ảo hơn RAM vật lý hỗ trợ vì OS có thể.paging các vùng ít truy cập ra đĩa. Tuy nhiên, việc truy cập một vùng đã bị paging ra gây ra lỗi trang tốn kém. Giữ heap compact và tránh các cấp phát thưa thớt lớn giữ dữ liệu nóng trong bộ nhớ vật lý và tránh các hình phạt này.
Stack, Heap và GC trong hành động
Mọi lời gọi phương thức minh họa bức tranh đầy đủ. Các biến cục bộ kiểu giá trị sống trên stack và được thu hồi ngay lập tức khi phương thức trả về. Các đối tượng kiểu tham chiếu sống trên heap và trở nên đủ điều kiện để thu gom khi không còn tham chiếu sống nào trỏ đến chúng.
public void ProcessOrder(int orderId)
{
// Stack: int và bool được cấp phát ở đây;
// tự động pop ra khi ProcessOrder trả về.
int retryCount = 0;
bool dispatched = false;
// Heap: Order là một class — CLR cấp phát nó trên managed heap.
var order = new Order(orderId);
// Khi ProcessOrder trả về:
// retryCount và dispatched → stack frame pop ra ngay lập tức
// order (tham chiếu) → pop ra khỏi stack
// Order object trên heap → không còn tham chiếu sống nào;
// đủ điều kiện cho lần thu gom GC tiếp theo
}
GC không thu gom các đối tượng ngay khi chúng trở nên unreachable — nó chạy theo lịch trình riêng, được kích hoạt bởi áp lực cấp phát. Thời gian không xác định này là tại sao .NET cung cấp mẫu IDisposable và các khối using cho các tài nguyên — kết nối cơ sở dữ liệu, handle tệp, socket mạng — cần được giải phóng ngay lập tức bất kể khi nào chu kỳ thu gom tiếp theo chạy.
Tóm tắt
Quản lý bộ nhớ trong .NET là tự động nhưng không vô hình. Một mô hình tinh thần vững chắc về stack, heap và GC giúp bạn viết mã chạy hiệu quả và tránh các cạm bẫy bộ nhớ phổ biến nhất.
- Garbage Collector là một thành phần CLR tự động theo dõi các cấp phát trên heap và thu hồi các đối tượng không còn reachable từ mã sống, loại bỏ gánh nặng giải phóng thủ công của các ngôn ngữ như C và C++.
- Stack lưu trữ các kiểu giá trị và biến cục bộ có kích thước đã biết tại thời điểm biên dịch và được thu hồi tự động khi một phương thức trả về. Heap lưu trữ các kiểu tham chiếu có kích thước động được quản lý bởi GC.
- Gán một kiểu giá trị sao chép giá trị — hai biến hoàn toàn độc lập. Gán một kiểu tham chiếu sao chép tham chiếu — cả hai biến trỏ cùng một đối tượng heap.
- Mỗi lần thu gom chạy ba pha: Mark (duyệt qua tất cả các tham chiếu sống từ GC roots), Sweep (thu hồi bộ nhớ của các đối tượng chết, để lại các khoảng trống), và Compact (trượt các survivor lại với nhau và cập nhật tất cả tham chiếu). Pha compact gây ra暂停 Stop-the-World; mô hình generational giữ hầu hết các暂停 ngắn bằng cách thu gom Gen 0 thường xuyên hơn nhiều so với Gen 1 hoặc Gen 2.
- Phân mảnh bộ nhớ rải rác các khoảng trống tự do trên toàn heap. Pha compact đóng chúng lại, tạo ra một block trống liền mạch duy nhất đáp ứng các cấp phát lớn hiệu quả.
- Mỗi process .NET chạy trong một vùng địa chỉ ảo isolated. Managed heap là một vùng được CLR kiểm soát trong vùng đó; các công cụ chẩn đoán bộ nhớ báo cáo áp lực liên quan đến vùng này, không phải tổng RAM vật lý.
- Thu gom GC là không xác định. Sử dụng mẫu IDisposable và các khối using cho các tài nguyên — handle tệp, kết nối cơ sở dữ liệu và socket mạng — yêu cầu giải phóng nhanh chóng, xác định.
Những kiến thức cơ bản này là nền tảng cho mọi thứ tiếp theo trong loạt bài này. Khi bạn hiểu tại sao các đối tượng rơi vào stack thay vì heap và tại sao xảy ra暂停 compact, mô hình thu gom generational của GC — các thế hệ 0, 1 và 2 cộng với Large Object Heap — sẽ có ý nghĩa ngay lập tức. Để có cái nhìn liên quan về cách tuổi thọ đối tượng tương tác với việc đăng ký service trong các ứng dụng thực tế, đọc Giới thiệu Dependency Injection trong .NET.



