1. Cơ bản về bộ nhớ: Stack và Heap (Stack & Heap)
Khi chương trình của bạn chạy, nó có truy cập vào bộ nhớ. Trong ngữ cảnh .NET (CLR — Common Language Runtime) bộ nhớ này được quản lý tự động và chia thành hai vùng chính: Stack và Heap. Hiểu được dữ liệu được lưu ở đâu và như thế nào rất quan trọng để viết mã hiệu quả và ổn định.
Stack
Hãy tưởng tượng Stack như một chồng đĩa: bạn luôn đặt đĩa mới lên trên cùng và lấy đĩa cũng từ trên cùng. Đó là nguyên tắc LIFO (Last In, First Out) — "vào sau, ra trước". Stack là vùng nhớ rất nhanh nhưng có giới hạn về kích thước.
Những gì được lưu trên stack:
- Value types: chính giá trị của các biến khai báo trực tiếp.
- Tham chiếu tới objects: với reference types trên stack chỉ lưu địa chỉ đến object trong heap.
- Parameters của method: giá trị được truyền vào hàm.
- Local variables: biến khai báo bên trong methods.
- Return addresses: nơi trả về sau khi method kết thúc.
Bộ nhớ trên stack được cấp phát và giải phóng tự động rất nhanh chóng khi method tương ứng kết thúc hoặc biến thoát scope.
Ví dụ: Value types trên stack
void MyMethod()
{
int a = 10; // 'a' và giá trị 10 - trên stack
bool flag = true; // 'flag' và giá trị true - trên stack
char initial = 'Z'; // 'initial' và giá trị 'Z' - trên stack
// ...
} // Khi MyMethod kết thúc, 'a', 'flag', 'initial' bị loại khỏi stack.
Ví dụ: Tham chiếu trên stack
class MyObject { }
void AnotherMethod()
{
MyObject objRef; // 'objRef' (tham chiếu) - trên stack. Object chưa được tạo.
// ...
} // Khi AnotherMethod kết thúc, 'objRef' (tham chiếu) bị loại khỏi stack.
Heap
Heap là vùng bộ nhớ lớn hơn và linh hoạt hơn. Ở đây không có thứ tự LIFO nghiêm ngặt; dữ liệu có thể nằm ở bất kỳ vị trí trống nào. Heap dùng cho các object lớn hơn và/hoặc sống lâu hơn.
Những gì được lưu trong heap:
- Objects của reference types: dữ liệu của class, arrays, string — được tạo bằng new.
- Value types nhúng: nếu value type (struct) là field của reference type (class), nó nằm bên trong object trên heap.
Việc quản lý bộ nhớ trên heap được thực hiện tự động bằng GC (Garbage Collector).
Ví dụ: Objects của class trên heap
class Person { public string Name; public int Age; }
void CreatePerson()
{
Person p = new Person(); // Object Person - trên heap. 'p' (tham chiếu) - trên stack.
p.Name = "Alice"; // Chuỗi "Alice" (cũng là object) - trên heap.
p.Age = 30; // 30 (int, value type) - bên trong object Person trên heap.
// ...
} // Khi CreatePerson kết thúc, 'p' (tham chiếu) bị loại khỏi stack.
// Object Person trên heap trở nên unreachable và sẽ bị GC dọn.
Ví dụ: Mảng trên heap
void ProcessArray()
{
int[] numbers = new int[5]; // Mảng 5 int - trên heap. 'numbers' (tham chiếu) - trên stack.
numbers[0] = 10; // Phần tử 10 - một phần của mảng trên heap.
// ...
} // Mảng sẽ được GC thu dọn khi không còn tham chiếu nào tới nó.
2. Garbage Collection
Quản lý bộ nhớ trên heap diễn ra tự động nhờ GC. Đây là một trong những tính năng chính của .NET, loại bỏ nhu cầu giải phóng bộ nhớ thủ công (nguồn phổ biến của memory leaks trong ngôn ngữ không có GC).
Mục đích và nguyên tắc hoạt động
Mục tiêu chính của GC là tự động giải phóng bộ nhớ do các object trên heap chiếm khi chúng không còn được chương trình sử dụng.
- Tạo object: khi dùng new CLR cấp phát chỗ trên heap.
- Theo dõi tham chiếu: GC theo dõi các tham chiếu; object "reachable" nếu có tham chiếu từ stack, static field hoặc từ object reachable khác.
- Xác định "rác": nếu không còn tham chiếu — object "unreachable" và được coi là rác.
- Thu gom: khi cần GC sẽ đánh dấu các object reachable, rồi giải phóng vùng nhớ của các object unreachable.
- Compact: để giảm fragmentation các object còn lại có thể được di chuyển, tạo vùng liên tục.
Generations trong GC (Generational GC)
GC dùng cơ chế generations vì hầu hết object "chết trẻ":
- Generation 0 (Gen 0): object mới; các lần thu gom thường xuyên và nhanh.
- Generation 1 (Gen 1): object sống sót qua Gen 0; thu gom ít thường xuyên hơn và lâu hơn.
- Generation 2 (Gen 2): object sống lâu; thu gom hiếm và tốn kém nhất.
Khi nào GC chạy?
- Thiếu bộ nhớ trống để cấp phát cho yêu cầu mới.
- Kiểm tra định kỳ trong thời gian idle.
- Gọi tường minh (thường không khuyến nghị) GC.Collect().
Hiệu suất của GC
Việc chạy GC có thể gây ra các pause ngắn vì code của user bị tạm dừng. Tạo quá nhiều object sống ngắn trong heap sẽ dẫn tới thu gom thường xuyên hơn và giảm hiệu suất.
Ví dụ: Object trở nên unreachable
class DataBlock { public byte[] Data; public DataBlock() => Data = new byte[1024 * 1024]; } // 1 MB dữ liệu
void AllocateAndLose()
{
DataBlock block1 = new DataBlock(); // Object block1 trên heap, tham chiếu 'block1' trên stack
// ... đoạn mã ...
block1 = null; // Bây giờ không còn tham chiếu đến DataBlock. Nó trở nên unreachable.
// Ở thời điểm này GC CHƯA NHẤT THIẾT đã xóa nó, nhưng nó là candidate để thu gom.
// Gọi GC.Collect() ở đây (chỉ để demo, đừng làm thế trong production!)
Console.WriteLine("Calling GC.Collect()");
GC.Collect(); // GC có thể (nhưng không đảm bảo) dọn bộ nhớ ngay bây giờ
}
AllocateAndLose();
// Khi AllocateAndLose kết thúc, tham chiếu 'block1' bị loại khỏi stack,
// và object DataBlock trên heap trở nên unreachable, đủ điều kiện để GC thu gom.
3. Quản lý unmanaged resources
GC quản lý bộ nhớ "managed" của .NET. Nhưng có unmanaged resources nằm ngoài tầm kiểm soát của GC và cần được giải phóng rõ ràng:
- File descriptors (file mở).
- Network sockets.
- Window/graphics handles (ví dụ GDI+).
- Bộ nhớ cấp phát từ OS (ví dụ qua P/Invoke).
Finalizers
Finalizer — method đặc biệt thực hiện dọn dẹp cuối cùng các unmanaged resources trước khi object bị GC loại bỏ. Cú pháp giống constructor nhưng có tilde:
class MyClassWithFinalizer
{
~MyClassWithFinalizer() { /* Giải phóng unmanaged resources */ }
}
GC được gọi không xác định thời điểm, có độ trễ, và chạy trên thread riêng cho finalizers. Hạn chế: không xác định thời điểm, chi phí tăng (object có finalizer cần hai lần pass). Vì vậy finalizers chỉ là "bảo hiểm", không phải cơ chế chính để dọn dẹp.
Ví dụ: Finalizer như "bảo hiểm"
class UnmanagedResourceHolder
{
private bool _resourceReleased = false;
// Mô phỏng unmanaged resource
public UnmanagedResourceHolder() => Console.WriteLine("Resurs sohodzon.");
public void ReleaseResource()
{
if (!_resourceReleased)
{
Console.WriteLine("Resurs OSVOBOJDEN yaasnim metodom.");
_resourceReleased = true;
}
}
// Finalizer - được GC gọi như biện pháp cuối cùng
~UnmanagedResourceHolder()
{
Console.WriteLine("Destruktor VYZVAN (resurs NE byl osvobozhden yavno).");
ReleaseResource(); // Cố gắng giải phóng resource
}
}
Interface IDisposable
IDisposable là cơ chế được khuyến nghị để giải phóng resources rõ ràng. Chứa một method: void Dispose(). Thường dùng pattern mà Dispose() có thể gọi nhiều lần và vô hiệu hóa finalizer bằng GC.SuppressFinalize(this).
Ví dụ: Triển khai IDisposable
using System.IO;
class MyFileWriter : IDisposable
{
private StreamWriter _writer;
private bool _disposed = false;
public MyFileWriter(string path)
{
_writer = new StreamWriter(path, true);
Console.WriteLine($"Fail '{path}' otkryt.");
}
public void WriteLog(string message) => _writer.WriteLine(message);
// Triển khai IDisposable
public void Dispose()
{
Dispose(true); // Gọi logic Dispose chính
GC.SuppressFinalize(this); // Nói với GC là finalizer không cần thiết
_disposed = true;
}
// Protected virtual method cho pattern Dispose chung
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
// Giải phóng managed resources
_writer?.Dispose();
}
// Giải phóng unmanaged resources (nếu có)
Console.WriteLine("Fail ZAKRYT cherez Dispose.");
}
}
// Finalizer tùy chọn như "bảo hiểm"
~MyFileWriter()
{
Console.WriteLine("Fail ZAKRYT cherez destruktor (Dispose() ne byl vyzvan).");
Dispose(false); // Gọi Dispose, báo rằng đây không phải gọi rõ ràng
}
}
Toán tử using
Toán tử using là syntactic sugar, đảm bảo Dispose() được gọi khi object thoát scope, ngay cả khi có exception. Đây là cách được khuyên dùng nhất khi làm việc với object implement IDisposable.
Ví dụ: Tự động dọn với using
// Sử dụng MyFileWriter từ ví dụ trên
void ProcessFile(string fileName)
{
// Object MyFileWriter sẽ tự động "dispose" sau khối using
using (var writer = new MyFileWriter(fileName))
{
writer.WriteLog("Pervaya stroka.");
writer.WriteLog("Vtoraya stroka.");
// Dù có exception xảy ra ở đây, Dispose() vẫn được gọi
} // Ở đây writer.Dispose() được gọi tự động
Console.WriteLine("Blok using zavershen, fail zakryt.");
}
ProcessFile("testlog.txt");
// Trong trường hợp này finalizer của MyFileWriter sẽ không được gọi, vì Dispose() đã được gọi rõ ràng.
Hiểu những khái niệm này là chìa khóa để viết ứng dụng C# hiệu năng cao, đáng tin cậy và dễ scale cho sinh viên mới học.
GO TO FULL VERSION