Garbage Collection & Memory Management
Overview
The .NET Garbage Collector (GC) is a sophisticated automatic memory management system. Understanding how it works is essential for writing high-performance applications and avoiding memory-related issues.
Generational Garbage Collection
The Three Generations
.NET uses a generational hypothesis: "Most objects die young."
┌─────────────────────────────────────────────────────┐
│ │
│ Gen 0 (Small, Short-lived) │
│ ◄─── Most collections happen here │
│ Objects: New allocations │
│ Size: ~256KB - 4MB │
│ │
├─────────────────────────────────────────────────────┤
│ │
│ Gen 1 (Medium, Transitional) │
│ ◄─── Buffer between Gen 0 and Gen 2 │
│ Objects: Survived one Gen 0 collection │
│ Size: ~2MB - 16MB │
│ │
├─────────────────────────────────────────────────────┤
│ │
│ Gen 2 (Large, Long-lived) │
│ ◄─── Expensive, infrequent collections │
│ Objects: Long-lived, survived multiple GCs │
│ Size: Limited by available memory │
│ │
└─────────────────────────────────────────────────────┘
Collection Triggers
// Gen 0: Triggers frequently
for (int i = 0; i < 1000; i++)
{
var temp = new byte[1000]; // Allocated in Gen 0
// Object becomes garbage immediately
}
// Many Gen 0 collections occurred
// Gen 1: Collects when Gen 0 survivors exceed threshold
var list = new List<string>();
for (int i = 0; i < 10000; i++)
{
list.Add(new string('*', 100)); // Objects survive to Gen 1
}
// Gen 2: Full collection, expensive
var largeObjects = new List<byte[]>();
for (int i = 0; i < 1000; i++)
{
largeObjects.Add(new byte[100_000]); // Eventually triggers Gen 2
}
Observing Generations
var obj = new object();
Console.WriteLine($"Initial: Gen {GC.GetGeneration(obj)}"); // 0
GC.Collect(0); // Gen 0 collection
Console.WriteLine($"After Gen 0: Gen {GC.GetGeneration(obj)}"); // 1
GC.Collect(1); // Gen 0 + Gen 1 collection
Console.WriteLine($"After Gen 1: Gen {GC.GetGeneration(obj)}"); // 2
// Generation counts
Console.WriteLine($"Gen 0 collections: {GC.CollectionCount(0)}");
Console.WriteLine($"Gen 1 collections: {GC.CollectionCount(1)}");
Console.WriteLine($"Gen 2 collections: {GC.CollectionCount(2)}");
Large Object Heap (LOH)
Objects ≥ 85,000 bytes go directly to the LOH (part of Gen 2).
// Small object: Gen 0
var small = new byte[84_000]; // Gen 0
Console.WriteLine($"Small: Gen {GC.GetGeneration(small)}"); // 0
// Large object: LOH (Gen 2)
var large = new byte[85_000]; // LOH
Console.WriteLine($"Large: Gen {GC.GetGeneration(large)}"); // 2
LOH Issues
Problem 1: Fragmentation
// LOH is NOT compacted by default (before .NET 4.5.1)
var arrays = new List<byte[]>();
for (int i = 0; i < 100; i++)
{
arrays.Add(new byte[90_000]); // LOH allocations
}
// Remove every other array
for (int i = arrays.Count - 1; i >= 0; i -= 2)
{
arrays.RemoveAt(i); // Creates holes in LOH
}
// LOH is now fragmented - can cause OutOfMemoryException
// even if there's enough total free space!
Solution: Compact LOH
// .NET 4.5.1+: Enable LOH compaction
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect(); // Next Gen 2 collection will compact LOH
Better Solution: Avoid LOH
// ❌ Creates many LOH allocations
public byte[] ProcessData(int size)
{
return new byte[100_000]; // LOH allocation
}
// ✅ Use ArrayPool to reuse buffers
public void ProcessData(int size)
{
var buffer = ArrayPool<byte>.Shared.Rent(100_000);
try
{
// Use buffer
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
Finalization
The Finalization Queue
Objects with finalizers add overhead to garbage collection.
// ❌ Finalizer adds overhead
public class ResourceHolder
{
private IntPtr _handle;
~ResourceHolder() // Finalizer
{
Cleanup(_handle);
// Object lives longer (survives to Gen 1/2)
// Finalization happens on separate thread
}
}
Finalization Process:
- Object is marked for finalization
- Object survives collection (moved to finalization queue)
- Finalizer runs on finalizer thread
- Object becomes eligible for collection in next GC
- Object is finally collected
Result: Objects with finalizers live much longer!
Proper Dispose Pattern
public class ResourceHolder : IDisposable
{
private IntPtr _handle;
private bool _disposed;
// Public Dispose method
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this); // Prevent finalizer from running
}
// Protected virtual Dispose
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
// Dispose managed resources
// Can safely access other managed objects
}
// Dispose unmanaged resources
if (_handle != IntPtr.Zero)
{
Cleanup(_handle);
_handle = IntPtr.Zero;
}
_disposed = true;
}
// Finalizer (only if you have unmanaged resources)
~ResourceHolder()
{
Dispose(false);
}
}
Key Points:
GC.SuppressFinalize(this)prevents finalizer from running- Finalizer only needed if you have unmanaged resources
Dispose(bool)is called from both Dispose and finalizer
IDisposable vs IAsyncDisposable
IDisposable (Synchronous)
public class FileProcessor : IDisposable
{
private FileStream _stream;
public FileProcessor(string path)
{
_stream = File.OpenWrite(path);
}
public void Dispose()
{
_stream?.Dispose(); // Synchronous disposal
}
}
// Usage
using (var processor = new FileProcessor("data.txt"))
{
// Use processor
} // Dispose called automatically
IAsyncDisposable (Asynchronous)
public class AsyncFileProcessor : IAsyncDisposable
{
private FileStream _stream;
public AsyncFileProcessor(string path)
{
_stream = File.OpenWrite(path);
}
public async ValueTask DisposeAsync()
{
if (_stream != null)
{
await _stream.FlushAsync(); // Async operation
await _stream.DisposeAsync();
}
}
}
// Usage
await using (var processor = new AsyncFileProcessor("data.txt"))
{
// Use processor
} // DisposeAsync called automatically
Implementing Both
public class HybridProcessor : IDisposable, IAsyncDisposable
{
private FileStream _stream;
private bool _disposed;
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
public async ValueTask DisposeAsync()
{
await DisposeAsyncCore().ConfigureAwait(false);
Dispose(false);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
_stream?.Dispose(); // Synchronous
}
_disposed = true;
}
protected virtual async ValueTask DisposeAsyncCore()
{
if (_stream != null)
{
await _stream.DisposeAsync().ConfigureAwait(false);
}
}
}
Weak References
Weak references allow GC to collect objects even if references exist.
// Strong reference - prevents GC
var strongRef = new byte[1_000_000];
GC.Collect();
// Object is NOT collected
// Weak reference - allows GC
var weakRef = new WeakReference<byte[]>(new byte[1_000_000]);
GC.Collect();
if (weakRef.TryGetTarget(out var target))
{
// Object still alive
Console.WriteLine("Still here!");
}
else
{
// Object was collected
Console.WriteLine("Gone!");
}
Cache with Weak References
public class WeakCache<TKey, TValue> where TValue : class
{
private readonly Dictionary<TKey, WeakReference<TValue>> _cache = new();
public bool TryGet(TKey key, out TValue value)
{
if (_cache.TryGetValue(key, out var weakRef))
{
return weakRef.TryGetTarget(out value);
}
value = default;
return false;
}
public void Add(TKey key, TValue value)
{
_cache[key] = new WeakReference<TValue>(value);
}
}
// Usage: Cache can be collected under memory pressure
var cache = new WeakCache<int, byte[]>();
cache.Add(1, new byte[10_000]);
GC.Collect(); // Might collect cached data
if (cache.TryGet(1, out var data))
{
Console.WriteLine("Cache hit!");
}
else
{
Console.WriteLine("Cache miss - was collected");
}
GC Modes
Workstation GC
- Default for client applications
- Optimized for low latency
- Runs on same threads as application
<!-- App.config or .csproj -->
<PropertyGroup>
<ServerGarbageCollection>false</ServerGarbageCollection>
</PropertyGroup>
Server GC
- Default for ASP.NET Core
- Optimized for throughput
- Dedicated GC threads per CPU core
- Larger heap segments
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
Concurrent vs Non-Concurrent
<!-- Concurrent (default) - allows app to run during Gen 2 collections -->
<PropertyGroup>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>
<!-- Non-Concurrent - pauses app during all collections -->
<PropertyGroup>
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
</PropertyGroup>
Memory Pressure
Inform GC about unmanaged memory usage:
public class NativeBuffer : IDisposable
{
private IntPtr _buffer;
private int _size;
public NativeBuffer(int size)
{
_size = size;
_buffer = Marshal.AllocHGlobal(size);
// Tell GC about memory pressure
GC.AddMemoryPressure(size);
}
public void Dispose()
{
if (_buffer != IntPtr.Zero)
{
Marshal.FreeHGlobal(_buffer);
// Remove memory pressure
GC.RemoveMemoryPressure(_size);
_buffer = IntPtr.Zero;
}
}
}
Best Practices
✅ DO
// Use using for IDisposable
using var stream = File.OpenRead("file.txt");
// Use await using for IAsyncDisposable
await using var asyncStream = new AsyncStream();
// Implement Dispose pattern correctly
public class Resource : IDisposable
{
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
// Use ArrayPool for temporary large buffers
var buffer = ArrayPool<byte>.Shared.Rent(100_000);
try { /* use */ } finally { ArrayPool<byte>.Shared.Return(buffer); }
❌ DON'T
// Don't call GC.Collect() manually (except special cases)
GC.Collect(); // Let GC decide when to collect
// Don't ignore IDisposable
var stream = File.OpenRead("file.txt");
// LEAK: Stream never disposed!
// Don't allocate large arrays frequently
for (int i = 0; i < 1000; i++)
{
var buffer = new byte[100_000]; // LOH pressure!
}
// Don't use finalizers unless absolutely necessary
~MyClass() { /* Adds GC overhead */ }
Interview Questions
Q: Explain the generational GC model.
A: .NET uses three generations. Gen 0 holds new objects and is collected frequently. Objects that survive move to Gen 1, which acts as a buffer. Long-lived objects end up in Gen 2, which is collected infrequently. This is based on the hypothesis that most objects die young.
Q: What is the Large Object Heap?
A: The LOH is part of Gen 2 and holds objects ≥ 85,000 bytes. It's not compacted by default, which can lead to fragmentation. Use ArrayPool or enable compaction to avoid issues.
Q: Why are finalizers expensive?
A: Objects with finalizers survive at least one GC cycle, are moved to a finalization queue, have their finalizer run on a separate thread, then must wait for another GC to be collected. This significantly extends object lifetime and adds overhead.
Q: What's the difference between IDisposable and IAsyncDisposable?
A: IDisposable is for synchronous cleanup with a Dispose() method. IAsyncDisposable is for asynchronous cleanup with a DisposeAsync() method returning ValueTask. Use IAsyncDisposable when disposal involves async I/O operations.
Exercises
See exercises/day3-4.md for hands-on practice with:
- Observing GC generations
- Implementing proper Dispose patterns
- Working with ArrayPool
- Measuring memory pressure and GC impact