Nhiều .NET dev không nghĩ về memory vì "có GC tự dọn". Nhưng GC không miễn phí - mỗi lần GC chạy, ứng dụng có thể pause. Hiểu cách GC hoạt động giúp bạn viết code nhanh hơn và predict được performance dưới load.
Mục tiêu
- Hiểu GC hoạt động thế nào (không cần sâu, nhưng đủ để ra quyết định)
- Biết allocation tốn bao nhiêu và cách giảm
- Hiểu
Span<T>vàstackallocở mức thực dụng - Biết khi nào optimize và khi nào stop
1) GC Hoạt Động Thế Nào - Phiên Bản Thực Dụng
Memory trong .NET chia làm 2 vùng:
Stack (per thread):
- Value types (int, double, struct)
- References (con trỏ đến heap objects)
- size nhỏ (~1MB per thread), TỰ ĐỘNG cleanup khi method return
- → NHANH, KHÔNG cần GC
Heap (shared):
- Reference types (class, string, array, delegate)
- Quản lý bởi GC
- → CHẬM hơn stack, CẦN GC
GC Generations:
Gen 0: objects mới tạo (short-lived) - GC thường xuyên, NHANH
Gen 1: objects sống sót Gen 0 - GC ít hơn
Gen 2: objects sống lâu (singleton, cache) - GC hiếm, CHẬM (full GC)
LOH: objects > 85KB - GC hiếm, dễ gây fragmentation
Tại sao quan trọng?
Mỗi lần GC chạy → App PAUSE (hoặc slow down)
Gen 0 collection: ~1ms → gần như không đáng kể
Gen 1 collection: ~5ms → OK
Gen 2 collection: ~50ms+ → CÓ THỂ CẢM NHẬN (jitter, latency spike)
Ứng dụng allocate NHIỀU objects → Gen 0 đầy nhanh → GC chạy liên tục
→ Throughput giảm 10-30%
→ Latency P99 tăng
Ứng dụng allocate ÍT objects → GC hiếm chạy → smooth performance
2) Allocation - Cái Gì Tốn, Cái Gì Không?
csharp// ✅ KHÔNG allocate (stack)
int x = 42; // Value type → stack
Span<byte> span = stackalloc byte[256]; // Stack allocation
var point = new Point(1, 2); // struct → stack (nếu local variable)
// ⚠️ ALLOCATE (heap)
var list = new List<int>(); // Class → heap
string name = "hello"; // String → heap (immutable, mỗi modify = new string)
var arr = new int[100]; // Array → heap
var user = new User(); // Class → heap
var lambda = () => { }; // Delegate → heap (có thể)
String - Thủ Phạm Allocation Số 1
csharp// ❌ String concatenation trong loop
string result = "";
for (int i = 0; i < 1000; i++)
{
result += i.ToString(); // MỖI LẦN += tạo string MỚI, copy toàn bộ cũ + mới
// Vòng 999: copy ~5000 chars + thêm "999" → allocate ~5KB string
}
// Tổng: ~500,000 string objects, ~2.5GB memory churn!
// ✅ StringBuilder
var sb = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
sb.Append(i); // Append vào buffer nội bộ, resize khi cần
}
var result = sb.ToString(); // 1 allocate cuối
// ✅ String interpolation cho ít items (compile-time optimize)
var message = $"User {name} has {count} orders"; // OK cho 2-3 items
// ✅ .NET 8+: String.Create cho high-performance
LINQ - Allocation Ẩn
csharp// LINQ tạo nhiều intermediate objects:
var result = items
.Where(x => x.IsActive) // Allocate WhereIterator + lambda
.Select(x => x.Name) // Allocate SelectIterator + lambda
.ToList(); // Allocate List<T> + copy
// Với hot path (gọi hàng triệu lần):
// → Xem xét dùng for loop thay LINQ
// → Hoặc chấp nhận nếu "hot" chỉ là vài nghìn lần/giây (thường đủ OK)
// ⚠️ LINQ trên small collections (<100 items): overhead KHÔNG đáng kể
// → Đừng "optimize" LINQ trừ khi profiler chỉ ra đó là bottleneck!
3) Span<T> - Cắt Data Không Allocate
csharp// Bài toán: parse "2024-01-15" thành ngày tháng
// ❌ Substring = allocate string mới
string date = "2024-01-15";
string year = date.Substring(0, 4); // Allocate "2024"
string month = date.Substring(5, 2); // Allocate "01"
string day = date.Substring(8, 2); // Allocate "15"
// → 3 string allocations
// ✅ Span = "window" vào string gốc, KHÔNG copy
ReadOnlySpan<char> dateSpan = date.AsSpan();
ReadOnlySpan<char> year = dateSpan[..4]; // "View" vào 4 chars đầu
ReadOnlySpan<char> month = dateSpan[5..7]; // "View" vào 2 chars giữa
ReadOnlySpan<char> day = dateSpan[8..10]; // "View" vào 2 chars cuối
// → 0 allocations!
int yearValue = int.Parse(year); // Parse trực tiếp từ span
Khi nào dùng Span?
✅ Parsing strings (logs, CSV, protocols)
✅ Binary data processing (image headers, file formats)
✅ Hot paths gọi hàng triệu lần
✅ Library code cần performance tối đa
❌ Business logic thông thường → overhead cognitive không đáng
❌ Khi data cần store/pass qua async → Span KHÔNG dùng với async!
(Span sống trên stack, async thay đổi stack → Memory<T> thay thế)
4) Object Pooling - Tái Sử Dụng Thay Vì Tạo Mới
csharp// Vấn đề: tạo object tốn kém lặp đi lặp lại
// Ví dụ: StringBuilder, byte[], MemoryStream
// ✅ ArrayPool: shared pool cho arrays
var pool = ArrayPool<byte>.Shared;
byte[] buffer = pool.Rent(4096); // Mượn buffer từ pool
try
{
// Dùng buffer...
var bytesRead = await stream.ReadAsync(buffer.AsMemory(0, 4096));
}
finally
{
pool.Return(buffer); // Trả lại pool (KHÔNG clear tự động!)
}
// ✅ ObjectPool: pool cho bất kỳ object nào
builder.Services.AddSingleton<ObjectPool<StringBuilder>>(
ObjectPool.Create<StringBuilder>());
public class ReportGenerator(ObjectPool<StringBuilder> pool)
{
public string Generate(ReportData data)
{
var sb = pool.Get();
try
{
sb.AppendLine("Report Header");
// ... build report
return sb.ToString();
}
finally
{
sb.Clear();
pool.Return(sb);
}
}
}
5) Performance Mindset - Khi Nào Optimize?
Quy tắc vàng: Đo trước, optimize sau
1. Code hoạt động ĐÚNG trước
2. Profiler chỉ ra BOTTLENECK ở đâu
3. Optimize CHỈ bottleneck đó
4. Đo lại xác nhận cải thiện
5. Dừng khi "đủ nhanh"
Optimize Pyramid
┌─────────┐
│ Span<T> │ ← Hiếm khi cần
│ stackalloc│
┌┴─────────┴┐
│ Pooling │ ← High-throughput services
┌┴───────────┴┐
│ Caching │ ← Hầu hết apps cần
┌┴─────────────┴┐
│ Query optimize│ ← Database là bottleneck #1
┌┴───────────────┴┐
│ Architecture │ ← Async, giải phóng threads
┌┴─────────────────┴┐
│ Algorithm & Data │ ← O(n²) → O(n log n)
│ Structure choice │
└───────────────────┘
Optimize TỪ DƯỚI LÊN. 90% performance gains đến từ:
1. Đúng algorithm/data structure
2. Async đúng cách (giải phóng threads)
3. Database queries hiệu quả (N+1, missing index)
4. Caching (Redis, in-memory)
Benchmarking nhanh
csharp// dotnet-counters: xem real-time metrics (không cần modify code)
// Terminal: dotnet-counters monitor --process-id <PID>
//
// Metrics quan trọng:
// GC Heap Size (Gen 0/1/2): memory usage
// Gen 0/1/2 Collections: frequency → nhiều = allocate nhiều
// ThreadPool Thread Count: tăng liên tục = thread starvation
// Exception Count: exceptions tốn kém!
// Working Set: total memory
// BenchmarkDotNet: so sánh 2 approach (cho library/hot path code)
[MemoryDiagnoser]
public class StringBenchmarks
{
[Benchmark(Baseline = true)]
public string Concatenation()
{
string s = "";
for (int i = 0; i < 100; i++) s += i;
return s;
}
[Benchmark]
public string StringBuilder()
{
var sb = new StringBuilder();
for (int i = 0; i < 100; i++) sb.Append(i);
return sb.ToString();
}
}
// Result: StringBuilder 50x nhanh hơn, 100x ít allocation
6) Những Thứ KHÔNG Đáng Optimize
❌ LINQ trên collection nhỏ (<1000 items) → overhead nanoseconds
❌ var vs explicit type → hoàn toàn compile-time, 0 runtime difference
❌ readonly struct cho 1-2 fields → GC handles small structs fine
❌ Premature caching → cache invalidation bugs > performance gain
❌ "Zero allocation" cho business logic → code phức tạp, hard to maintain
❌ String.Intern cho data động → memory leak (interned strings live forever)
Master Checklist ✅
□ String concatenation trong loop → StringBuilder?
□ Read-only queries dùng AsNoTracking? (EF Core)
□ LINQ không chạy trên hot path hàng triệu lần unnecessary?
□ Large arrays dùng ArrayPool thay new?
□ Database là bottleneck? (index, query optimize trước khi code optimize)
□ Đã đo bằng profiler trước khi optimize?
□ Caching có invalidation strategy không?
Tư duy master: "Premature optimization is the root of all evil" - Donald Knuth. Nhưng kiến thức về performance không bao giờ premature. Biết cách GC hoạt động giúp bạn viết code tự nhiên tốt hơn, không cần nghĩ nhiều.