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
-
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.
-
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).
-
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.
-
Explain the IDisposable pattern.
- Provides deterministic cleanup for unmanaged resources. Implement Dispose() to release resources, use
usingstatement for automatic disposal, implement finalizer if needed.
- Provides deterministic cleanup for unmanaged resources. Implement Dispose() to release resources, use
-
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.
-
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).
-
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.