Skip to main content

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:

  1. Object is marked for finalization
  2. Object survives collection (moved to finalization queue)
  3. Finalizer runs on finalizer thread
  4. Object becomes eligible for collection in next GC
  5. 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