Memory Management & Garbage Collection

Master .NET garbage collection, heap allocation, memory profiling, and leak prevention for high-performance applications

C#
Performance
GC
Memory
Optimization
25 min read

Overview

Memory management directly impacts application performance and scalability. For mid to senior roles, understanding GC behavior, memory allocation patterns, and common pitfalls is essential for technical interviews and production code optimization.

Core Concepts

GC Generations in Detail

// Generation promotion example
public class MemoryDemo
{
    // Short-lived - Gen 0, quickly collected
    public void CreateTempObjects()
    {
        for (int i = 0; i < 1000; i++)
        {
            var temp = new byte[100];  // Gen 0
        } // Objects die immediately, collected in next Gen 0 GC
    }

    // Long-lived - promoted to Gen 2
    private static readonly List<Customer> _cache = new();  // Gen 2

    public void AddToCache(Customer customer)
    {
        _cache.Add(customer);  // Survives collections, promoted to Gen 2
    }
}

// Check object generation
public void CheckGeneration(object obj)
{
    int generation = GC.GetGeneration(obj);
    Console.WriteLine($"Object is in generation: {generation}");
}

Large Object Heap (LOH)

// LOH allocation (>= 85,000 bytes)
public class LargeObjectDemo
{
    // Goes directly to LOH
    public byte[] CreateLargeArray()
    {
        // 85KB+ allocated on LOH
        return new byte[85_000];  // LOH
    }

    // Multiple small objects - regular heap
    public List<byte[]> CreateSmallArrays()
    {
        var list = new List<byte[]>();
        for (int i = 0; i < 1000; i++)
        {
            list.Add(new byte[1000]);  // Regular heap (Gen 0)
        }
        return list;
    }

    // Problem: LOH fragmentation
    public void CauseLOHFragmentation()
    {
        var arrays = new List<byte[]>();

        // Allocate many large objects
        for (int i = 0; i < 100; i++)
        {
            arrays.Add(new byte[100_000]);
        }

        // Remove every other one - creates holes
        for (int i = arrays.Count - 1; i >= 0; i -= 2)
        {
            arrays.RemoveAt(i);  // Fragmentation!
        }
    }
}

// Solution: Object pooling for large objects
public class LargeBufferPool
{
    private readonly Stack<byte[]> _pool = new();
    private readonly int _bufferSize;

    public LargeBufferPool(int bufferSize = 100_000)
    {
        _bufferSize = bufferSize;
    }

    public byte[] Rent()
    {
        return _pool.Count > 0 ? _pool.Pop() : new byte[_bufferSize];
    }

    public void Return(byte[] buffer)
    {
        Array.Clear(buffer);  // Clear for security
        _pool.Push(buffer);
    }
}

Memory Leaks

// Common leak: Event handler not unsubscribed
public class LeakyPublisher
{
    public event EventHandler? DataChanged;

    public void NotifyChange() => DataChanged?.Invoke(this, EventArgs.Empty);
}

public class LeakySubscriber
{
    private readonly LeakyPublisher _publisher;

    public LeakySubscriber(LeakyPublisher publisher)
    {
        _publisher = publisher;
        _publisher.DataChanged += OnDataChanged;  // Leak if not unsubscribed!
    }

    private void OnDataChanged(object? sender, EventArgs e) { }

    // Missing: Unsubscribe in Dispose
}

// Fixed: Proper cleanup
public class FixedSubscriber : IDisposable
{
    private readonly LeakyPublisher _publisher;
    private bool _disposed;

    public FixedSubscriber(LeakyPublisher publisher)
    {
        _publisher = publisher;
        _publisher.DataChanged += OnDataChanged;
    }

    private void OnDataChanged(object? sender, EventArgs e) { }

    public void Dispose()
    {
        if (!_disposed)
        {
            _publisher.DataChanged -= OnDataChanged;  // Unsubscribe
            _disposed = true;
        }
    }
}

Static References Leak

// Bad: Static collection grows unbounded
public class CacheManager
{
    private static readonly Dictionary<string, Customer> _cache = new();

    public void AddToCache(string key, Customer customer)
    {
        _cache[key] = customer;  // Never removed - memory leak!
    }
}

// Good: Bounded cache with eviction
public class BoundedCache<TKey, TValue> where TKey : notnull
{
    private readonly Dictionary<TKey, CacheEntry<TValue>> _cache = new();
    private readonly int _maxSize;

    public BoundedCache(int maxSize = 1000)
    {
        _maxSize = maxSize;
    }

    public void Add(TKey key, TValue value)
    {
        // Evict oldest if full
        if (_cache.Count >= _maxSize)
        {
            var oldest = _cache.OrderBy(x => x.Value.Timestamp).First();
            _cache.Remove(oldest.Key);
        }

        _cache[key] = new CacheEntry<TValue>(value, DateTime.UtcNow);
    }

    public bool TryGet(TKey key, out TValue? value)
    {
        if (_cache.TryGetValue(key, out var entry))
        {
            value = entry.Value;
            return true;
        }
        value = default;
        return false;
    }

    private record CacheEntry<T>(T Value, DateTime Timestamp);
}

Bad vs Good Examples

Bad: Frequent LOH Allocations

// Bad - creates LOH pressure
public byte[] ProcessLargeData(string filePath)
{
    var buffer = new byte[1_000_000];  // LOH allocation every call
    using var stream = File.OpenRead(filePath);
    stream.Read(buffer, 0, buffer.Length);
    return buffer;
}

Good: Buffer Pooling

// Good - reuse buffers
public class DataProcessor
{
    private static readonly ArrayPool<byte> _pool = ArrayPool<byte>.Shared;

    public byte[] ProcessLargeData(string filePath)
    {
        var buffer = _pool.Rent(1_000_000);  // Rent from pool
        try
        {
            using var stream = File.OpenRead(filePath);
            int read = stream.Read(buffer, 0, buffer.Length);

            var result = new byte[read];
            Array.Copy(buffer, result, read);
            return result;
        }
        finally
        {
            _pool.Return(buffer);  // Return to pool
        }
    }
}

Bad: Boxing in Hot Path

// Bad - boxing value types
public void LogValues(List<int> numbers)
{
    foreach (var num in numbers)
    {
        Console.WriteLine($"Value: {num}");  // Boxing int -> object

        object boxed = num;  // Explicit boxing - heap allocation
        _cache.Add(boxed);   // Gen 0 pressure
    }
}

Good: Avoid Boxing

// Good - no boxing
public void LogValues(List<int> numbers)
{
    foreach (var num in numbers)
    {
        Console.WriteLine($"Value: {num}");  // No boxing with interpolation
    }
}

// Use generic constraints to avoid boxing
public class ValueCache<T> where T : struct
{
    private readonly List<T> _cache = new();

    public void Add(T value)
    {
        _cache.Add(value);  // No boxing
    }
}

Interview Tips

Tip 1: Explain that Gen 0 collections are fast and frequent, while Gen 2 (full GC) collections are expensive. Design for short-lived objects.

Tip 2: LOH is for objects >= 85,000 bytes and isn't compacted by default. Use pooling to avoid LOH fragmentation.

Tip 3: Memory leaks in .NET usually involve event handlers, static references, or captured closures keeping objects alive.

Common Interview Questions

  1. What are the three GC generations and why do they exist?

    • Gen 0 (short-lived), Gen 1 (buffer), Gen 2 (long-lived). Generational GC is based on the hypothesis that young objects die quickly. Most collections are Gen 0 only, which is fast.
  2. When does an object get allocated on the Large Object Heap?

    • Objects >= 85,000 bytes go directly to LOH. LOH is part of Gen 2 but isn't compacted by default (since .NET Core 2.1, can be configured).
  3. What causes memory leaks in .NET?

    • Event handlers not unsubscribed, static collection growth, captured closures in long-lived delegates, unmanaged resources not disposed, timer/thread references.
  4. Explain the IDisposable pattern.

    • Provides deterministic cleanup for unmanaged resources. Implement Dispose() to release resources, use using statement for automatic disposal, implement finalizer if needed.
  5. What's the difference between Dispose and Finalize?

    • Dispose is explicit, deterministic cleanup called by code. Finalize is implicit, called by GC before collection, non-deterministic, should only clean unmanaged resources.
  6. How do you prevent LOH fragmentation?

    • Use ArrayPool for large buffers, implement object pooling, avoid repeated allocation/deallocation of large objects, consider compacting LOH (GCSettings.LargeObjectHeapCompactionMode).
  7. What's the impact of Gen 2 collections?

    • Full GC collections pause all threads, expensive, affects latency. Minimize by: keeping objects short-lived, using object pools, avoiding large object allocations.