C# Memory Management and Garbage Collection
Memory Fundamentals
The .NET runtime manages memory automatically through the garbage collector (GC). Understanding how it works helps you write efficient code and avoid memory-related issues.
Stack vs Heap
public void MemoryExample()
{
// Stack allocation - value types and references
int count = 42; // Value stored on stack
double price = 19.99; // Value stored on stack
// Heap allocation - objects
var customer = new Customer(); // Reference on stack, object on heap
string name = "Alice"; // Reference on stack, string on heap
int[] numbers = new int[100]; // Reference on stack, array on heap
}
Stack
- Allocation: Very fast (pointer move)
- Deallocation: Automatic (scope exit)
- Size: Small (~1MB per thread)
- Lifetime: Method scope
- Content: Value types, references
Heap
- Allocation: Slower (GC managed)
- Deallocation: GC determines when
- Size: Large (limited by RAM)
- Lifetime: GC decides
- Content: Objects, arrays
The Managed Heap
.NET divides the managed heap into generations based on object lifetime:
- Generation 0 (Gen0): Newly allocated objects. Most objects die young.
- Generation 1 (Gen1): Survived one GC. Buffer between Gen0 and Gen2.
- Generation 2 (Gen2): Long-lived objects. Expensive to collect.
- Large Object Heap (LOH): Objects >= 85,000 bytes. Collected with Gen2.
- Pinned Object Heap (POH): .NET 5+. Pinned objects to avoid fragmentation.
// Check which generation an object is in
var obj = new byte[1000];
int generation = GC.GetGeneration(obj); // Usually 0 for new objects
// After surviving collections
GC.Collect(0); // Gen0 collection
generation = GC.GetGeneration(obj); // Now in Gen1
How Garbage Collection Works
GC Triggers
Garbage collection runs when:
- Gen0 threshold reached (most common)
- System memory pressure
GC.Collect()called explicitly- Application is idle (workstation GC)
Collection Process
- Mark: Identify live objects by tracing from roots (statics, stack, CPU registers)
- Sweep/Compact: Remove dead objects, compact memory (except LOH by default)
- Promote: Move surviving objects to next generation
// GC roots include:
// - Static variables
// - Local variables on stack
// - CPU registers
// - Finalization queue
// - GC handles (GCHandle)
public class RootExample
{
private static Customer? _staticCustomer; // GC root
public void Method()
{
var local = new Customer(); // GC root while in scope
_staticCustomer = local; // Now rooted by static field
} // local goes out of scope, but object still rooted by static
}
GC Modes
// Check current GC settings
bool isServer = GCSettings.IsServerGC;
GCLatencyMode latency = GCSettings.LatencyMode;
// Server GC: One heap per CPU core, parallel collection
// Workstation GC: Single heap, concurrent collection
// Configure in project file
// <ServerGarbageCollection>true</ServerGarbageCollection>
| Mode | Best For | Characteristics |
|---|---|---|
| Workstation | Desktop apps | Lower latency, one heap |
| Server | Web servers | Higher throughput, parallel |
| Concurrent | UI apps | Background collection |
| Background | Most apps | Default, non-blocking Gen2 |
Latency Modes
// Temporarily suppress GC for latency-critical sections
var oldMode = GCSettings.LatencyMode;
try
{
GCSettings.LatencyMode = GCLatencyMode.LowLatency;
PerformLatencyCriticalWork();
}
finally
{
GCSettings.LatencyMode = oldMode;
}
// Available modes:
// - Batch: Max throughput, full blocking collections
// - Interactive: Default, balanced
// - LowLatency: Minimize pauses (may increase memory)
// - SustainedLowLatency: Long-term low latency
// - NoGCRegion: Prevent GC entirely (limited allocation)
IDisposable and Resource Management
The GC handles memory, but unmanaged resources (files, connections, handles) need explicit cleanup.
The Dispose Pattern
public class ResourceHolder : IDisposable
{
private FileStream? _fileStream;
private bool _disposed;
public ResourceHolder(string path)
{
_fileStream = new FileStream(path, FileMode.Open);
}
public void DoWork()
{
ObjectDisposedException.ThrowIf(_disposed, this);
// Use _fileStream
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this); // No need for finalizer
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
// Dispose managed resources
_fileStream?.Dispose();
_fileStream = null;
}
// Free unmanaged resources here (rare)
_disposed = true;
}
}
// Usage
using var holder = new ResourceHolder("file.txt");
holder.DoWork();
// Automatically disposed at end of scope
Finalizers (Destructors)
Finalizers are a safety net for unmanaged resources if Dispose isn’t called. They have significant performance cost.
public class UnmanagedWrapper : IDisposable
{
private IntPtr _handle; // Unmanaged resource
private bool _disposed;
public UnmanagedWrapper()
{
_handle = NativeMethods.CreateResource();
}
~UnmanagedWrapper() // Finalizer
{
Dispose(disposing: false);
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this); // Don't run finalizer
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
// Dispose managed resources
}
// Always free unmanaged resources
if (_handle != IntPtr.Zero)
{
NativeMethods.CloseResource(_handle);
_handle = IntPtr.Zero;
}
_disposed = true;
}
}
Finalizer Costs
- Objects with finalizers survive Gen0 collection (promoted to Gen1)
- Finalizers run on dedicated thread (delays cleanup)
- Finalizer exceptions can crash the app
- Use only when wrapping unmanaged resources directly
Prefer SafeHandle over manual finalizers whenever possible.
// Prefer SafeHandle over manual finalizers
public class SafeResourceHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public SafeResourceHandle() : base(true) { }
protected override bool ReleaseHandle()
{
return NativeMethods.CloseResource(handle);
}
}
Memory Allocation Patterns
Reducing Allocations
// BAD: Allocates new string each call
public string GetGreeting(string name)
{
return $"Hello, {name}!"; // String allocation
}
// GOOD: Use Span for parsing without allocation
public int ParseNumber(ReadOnlySpan<char> input)
{
int index = input.IndexOf(':');
var numberSpan = input[(index + 1)..].Trim();
return int.Parse(numberSpan); // No string allocation
}
// BAD: LINQ creates many small allocations
public int SumEven(int[] numbers)
{
return numbers.Where(n => n % 2 == 0).Sum(); // Allocates enumerator
}
// GOOD: Manual loop avoids allocations
public int SumEvenNoAlloc(int[] numbers)
{
int sum = 0;
foreach (var n in numbers)
if (n % 2 == 0) sum += n;
return sum;
}
ArrayPool for Temporary Buffers
// BAD: Frequent allocation of temporary arrays
public byte[] ProcessData(Stream source)
{
var buffer = new byte[4096]; // Allocation
source.Read(buffer, 0, buffer.Length);
return Transform(buffer);
}
// GOOD: Rent from pool
public byte[] ProcessDataPooled(Stream source)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
int read = source.Read(buffer, 0, 4096);
return Transform(buffer.AsSpan(0, read));
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
Object Pooling
using Microsoft.Extensions.ObjectPool;
// Configure pool
var policy = new DefaultPooledObjectPolicy<StringBuilder>();
var pool = new DefaultObjectPool<StringBuilder>(policy, maximumRetained: 100);
// Use pooled object
public string BuildReport(IEnumerable<Item> items)
{
var sb = pool.Get();
try
{
foreach (var item in items)
sb.AppendLine($"{item.Name}: {item.Value}");
return sb.ToString();
}
finally
{
sb.Clear();
pool.Return(sb);
}
}
Value Types to Avoid Heap Allocation
// Class - allocated on heap
public class PointClass
{
public int X { get; set; }
public int Y { get; set; }
}
// Struct - allocated on stack (when local) or inline
public struct PointStruct
{
public int X { get; set; }
public int Y { get; set; }
}
// Record struct combines value semantics with record features
public readonly record struct PointRecord(int X, int Y);
// Array of structs: single allocation, values inline
PointStruct[] structArray = new PointStruct[1000]; // One allocation
// Array of classes: 1001 allocations (array + each object)
PointClass[] classArray = new PointClass[1000];
for (int i = 0; i < 1000; i++)
classArray[i] = new PointClass(); // 1000 additional allocations
Large Object Heap
Objects >= 85,000 bytes go to the LOH. Different collection rules apply.
// LOH threshold
const int LohThreshold = 85_000;
// This goes to SOH (Small Object Heap)
var smallArray = new byte[84_000];
// This goes to LOH
var largeArray = new byte[86_000];
// LOH considerations:
// - Collected with Gen2 (expensive)
// - Not compacted by default (fragmentation)
// - Survives longer in memory
// Enable LOH compaction (use sparingly)
GCSettings.LargeObjectHeapCompactionMode =
GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect(); // Compact happens on next collection
Avoiding LOH Fragmentation
// Strategy 1: Use ArrayPool for large buffers
var buffer = ArrayPool<byte>.Shared.Rent(100_000);
try
{
// Use buffer
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
// Strategy 2: Pre-allocate and reuse
public class LargeBufferPool
{
private readonly byte[][] _buffers;
private int _index;
public LargeBufferPool(int count, int size)
{
_buffers = new byte[count][];
for (int i = 0; i < count; i++)
_buffers[i] = new byte[size];
}
public byte[] Rent() => _buffers[_index++ % _buffers.Length];
}
Memory Diagnostics
Monitoring GC
// GC statistics
int gen0Collections = GC.CollectionCount(0);
int gen1Collections = GC.CollectionCount(1);
int gen2Collections = GC.CollectionCount(2);
long totalMemory = GC.GetTotalMemory(forceFullCollection: false);
// Detailed info
GCMemoryInfo info = GC.GetGCMemoryInfo();
Console.WriteLine($"Heap size: {info.HeapSizeBytes}");
Console.WriteLine($"Fragmented: {info.FragmentedBytes}");
Console.WriteLine($"High memory: {info.HighMemoryLoadThresholdBytes}");
// Generation sizes
foreach (var genInfo in info.GenerationInfo)
{
Console.WriteLine($"Gen{genInfo.Generation}: {genInfo.SizeAfterBytes}");
}
Finding Memory Leaks
Common leak patterns:
- Event handlers not unsubscribed
- Static collections growing unbounded
- Closures capturing objects unintentionally
- Circular references with weak references
// LEAK: Event handler keeps subscriber alive
public class Publisher
{
public event EventHandler? DataChanged;
}
public class Subscriber
{
public Subscriber(Publisher pub)
{
pub.DataChanged += OnDataChanged; // Publisher references Subscriber
}
private void OnDataChanged(object? sender, EventArgs e) { }
}
// FIX: Unsubscribe or use weak events
public class SafeSubscriber : IDisposable
{
private readonly Publisher _publisher;
public SafeSubscriber(Publisher pub)
{
_publisher = pub;
_publisher.DataChanged += OnDataChanged;
}
public void Dispose()
{
_publisher.DataChanged -= OnDataChanged;
}
}
WeakReference for Caches
public class WeakCache<TKey, TValue> where TKey : notnull where TValue : class
{
private readonly Dictionary<TKey, WeakReference<TValue>> _cache = new();
public void Add(TKey key, TValue value)
{
_cache[key] = new WeakReference<TValue>(value);
}
public TValue? Get(TKey key)
{
if (_cache.TryGetValue(key, out var weakRef))
{
if (weakRef.TryGetTarget(out var value))
return value;
_cache.Remove(key); // Clean up dead reference
}
return null;
}
}
GC Control (Use Sparingly)
// Force collection (rarely needed)
GC.Collect(); // All generations
GC.Collect(0); // Gen0 only
GC.Collect(2, GCCollectionMode.Optimized); // Let GC decide
// Wait for finalizers
GC.WaitForPendingFinalizers();
// No-GC region for real-time scenarios
if (GC.TryStartNoGCRegion(1024 * 1024)) // 1MB allocation budget
{
try
{
PerformRealTimeWork();
}
finally
{
GC.EndNoGCRegion();
}
}
// Keep object alive past last use
void ProcessWithHandle(object resource)
{
var handle = CreateHandle(resource);
Process(handle);
GC.KeepAlive(resource); // Ensure resource not collected during Process
}
Best Practices
Do
- Let GC manage memory automatically
- Use
usingstatements for IDisposable resources - Pool frequently allocated temporary objects
- Prefer value types for small, immutable data
- Use Span
for slicing without allocation
Don’t
- Call
GC.Collect()without profiling justification - Implement finalizers unless wrapping unmanaged resources directly
- Hold references longer than needed
- Allocate large objects frequently
- Ignore memory warnings from profilers
Key Takeaways
Trust the GC: It’s highly optimized. Manual intervention rarely helps and often hurts.
Reduce allocations: Fewer allocations means less GC work. Use pooling, Span
Dispose deterministically: Use using for unmanaged resources. Don’t rely on finalizers for cleanup.
Profile before optimizing: Use profilers to identify actual memory issues before adding complexity.
Watch for leaks: Event handlers, static collections, and captured closures are common leak sources.
LOH awareness: Large objects have different lifecycle. Pool them or break into smaller chunks when possible.
Found this guide helpful? Share it with your team:
Share on LinkedIn