Performance and Memory Management

Garbage collection, the stack/heap, memory leaks, Span<T>, object pooling, and techniques for writing allocation-efficient .NET code.

Garbage Collection (GC) is .NET's automatic memory management system that reclaims memory occupied by unused objects.

How Garbage Collection Works:

  1. Mark Phase: GC identifies which objects are still in use by traversing object references from roots (static fields, local variables, CPU registers)
  2. Sweep Phase: GC removes unreachable objects
  3. Compact Phase: GC moves surviving objects together to reduce fragmentation

GC Generations:

.NET uses a generational garbage collection model with three generations:

Generation 0 (Gen 0) - Young objects
├── Short-lived objects
├── Temporary variables
├── Fast collection (< 1ms typically)
└── Most frequent collections

Generation 1 (Gen 1) - Middle-aged objects
├── Survived one Gen 0 collection
├── Buffer between Gen 0 and Gen 2
├── Fast collection
└── Intermediate frequency

Generation 2 (Gen 2) - Old objects
├── Long-lived objects
├── Static data
├── Slower collection (can be 100ms+)
└── Least frequent collections

Large Object Heap (LOH)
├── Objects > 85,000 bytes
├── Not compacted by default
├── Collected with Gen 2
└── Can cause fragmentation

Generation Promotion:

// Example of object lifetime
public void DemonstrateGenerations()
{
    // Gen 0 - temporary object
    var temp = new byte[100];
    
    // Gen 0 collection happens
    GC.Collect(0);
    
    // 'temp' survives, promoted to Gen 1
    
    // More Gen 0 collections...
    GC.Collect(0);
    
    // 'temp' still alive, promoted to Gen 2
    
    Console.WriteLine($"Generation: {GC.GetGeneration(temp)}");
}

GC Modes:

1. Workstation GC

  • For client applications
  • Optimized for responsiveness
  • Runs on same thread that triggered collection
// In .csproj

  false

2. Server GC

  • For server applications
  • Optimized for throughput
  • Multiple GC threads (one per CPU)
  • Larger heap segments
// In .csproj

  true

GC Collection Types:

// Check GC mode
bool isServerGC = GCSettings.IsServerGC;
Console.WriteLine($"Server GC: {isServerGC}");

// Check latency mode
Console.WriteLine($"Latency Mode: {GCSettings.LatencyMode}");

// Set latency mode
GCSettings.LatencyMode = GCLatencyMode.LowLatency; // For time-critical operations

// Force garbage collection (avoid in production!)
GC.Collect(); // Full collection
GC.Collect(0); // Gen 0 only
GC.Collect(2, GCCollectionMode.Optimized);

// Wait for finalization
GC.WaitForPendingFinalizers();

// Get GC stats
for (int i = 0; i <= GC.MaxGeneration; i++)
{
    Console.WriteLine($"Gen {i} collections: {GC.CollectionCount(i)}");
}

GC Notifications:

// Register for GC notifications
GC.RegisterForFullGCNotification(10, 10);

// In a monitoring thread
while (true)
{
    GCNotificationStatus status = GC.WaitForFullGCApproach();
    if (status == GCNotificationStatus.Succeeded)
    {
        Console.WriteLine("Full GC is approaching...");
        // Prepare: redirect traffic, pause operations, etc.
    }
    
    status = GC.WaitForFullGCComplete();
    if (status == GCNotificationStatus.Succeeded)
    {
        Console.WriteLine("Full GC completed.");
        // Resume normal operations
    }
}

GC Performance Tips:

// 1. Reuse objects when possible
private static readonly StringBuilder _builder = new StringBuilder();

public string BuildString(params string[] parts)
{
    _builder.Clear();
    foreach (var part in parts)
        _builder.Append(part);
    return _builder.ToString();
}

// 2. Use object pooling
private static readonly ObjectPool _pool = 
    new DefaultObjectPool(new StringBuilderPooledObjectPolicy());

public string BuildStringPooled(params string[] parts)
{
    var builder = _pool.Get();
    try
    {
        foreach (var part in parts)
            builder.Append(part);
        return builder.ToString();
    }
    finally
    {
        _pool.Return(builder);
    }
}

// 3. Use structs for small, short-lived data
public struct Point // Value type, stack allocated
{
    public int X { get; set; }
    public int Y { get; set; }
}

// 4. Avoid finalizers unless necessary
public class ResourceHolder : IDisposable
{
    private bool _disposed;
    
    public void Dispose()
    {
        if (!_disposed)
        {
            // Clean up managed resources
            _disposed = true;
            GC.SuppressFinalize(this); // Prevent finalizer call
        }
    }
    
    // Only add finalizer if holding unmanaged resources
    ~ResourceHolder()
    {
        Dispose();
    }
}

GC Pressure:

// Add memory pressure for unmanaged resources
public class UnmanagedResourceHolder : IDisposable
{
    private IntPtr _unmanagedMemory;
    private const long ResourceSize = 1024 * 1024; // 1MB
    
    public UnmanagedResourceHolder()
    {
        _unmanagedMemory = Marshal.AllocHGlobal((int)ResourceSize);
        GC.AddMemoryPressure(ResourceSize); // Tell GC about unmanaged memory
    }
    
    public void Dispose()
    {
        if (_unmanagedMemory != IntPtr.Zero)
        {
            Marshal.FreeHGlobal(_unmanagedMemory);
            GC.RemoveMemoryPressure(ResourceSize);
            _unmanagedMemory = IntPtr.Zero;
        }
    }
}

Weak References:

// Hold reference without preventing collection
public class CacheManager
{
    private readonly Dictionary> _cache = new();
    
    public void AddToCache(string key, byte[] data)
    {
        _cache[key] = new WeakReference(data);
    }
    
    public byte[] GetFromCache(string key)
    {
        if (_cache.TryGetValue(key, out var weakRef) && 
            weakRef.TryGetTarget(out var data))
        {
            return data; // Object still alive
        }
        return null; // Object was collected
    }
}

Best Practices:

  • Don't call GC.Collect() manually in production
  • Use IDisposable for deterministic cleanup
  • Minimize allocations in hot paths
  • Use structs for small, short-lived data
  • Pool objects for frequently allocated types
  • Monitor GC metrics in production

A memory leak in .NET occurs when objects that are no longer needed remain referenced, preventing garbage collection.

Common Causes of Memory Leaks:

1. Event Handler Leaks

// BAD: Memory leak
public class Publisher
{
    public event EventHandler OnDataChanged;
}

public class Subscriber
{
    public Subscriber(Publisher publisher)
    {
        publisher.OnDataChanged += HandleDataChanged; // Leak!
    }
    
    private void HandleDataChanged(object sender, EventArgs e)
    {
        // Handle event
    }
}

// GOOD: Proper cleanup
public class Subscriber : IDisposable
{
    private readonly Publisher _publisher;
    
    public Subscriber(Publisher publisher)
    {
        _publisher = publisher;
        _publisher.OnDataChanged += HandleDataChanged;
    }
    
    public void Dispose()
    {
        _publisher.OnDataChanged -= HandleDataChanged; // Unsubscribe!
    }
    
    private void HandleDataChanged(object sender, EventArgs e)
    {
        // Handle event
    }
}

2. Static References

// BAD: Keeps all users in memory forever
public static class UserCache
{
    private static readonly List _users = new List();
    
    public static void AddUser(User user)
    {
        _users.Add(user); // Never released!
    }
}

// GOOD: Use weak references or implement eviction
public static class UserCache
{
    private static readonly Dictionary> _users = new();
    
    public static void AddUser(User user)
    {
        _users[user.Id] = new WeakReference(user);
    }
    
    public static User GetUser(int id)
    {
        if (_users.TryGetValue(id, out var weakRef) && 
            weakRef.TryGetTarget(out var user))
        {
            return user;
        }
        return null;
    }
}

3. Timer Leaks

// BAD: Timer keeps object alive
public class DataRefresher
{
    private Timer _timer;
    
    public DataRefresher()
    {
        _timer = new Timer(RefreshData, null, 0, 1000);
    }
    
    private void RefreshData(object state)
    {
        // Refresh logic
    }
    
    // No disposal - leak!
}

// GOOD: Dispose timer
public class DataRefresher : IDisposable
{
    private Timer _timer;
    
    public DataRefresher()
    {
        _timer = new Timer(RefreshData, null, 0, 1000);
    }
    
    private void RefreshData(object state)
    {
        // Refresh logic
    }
    
    public void Dispose()
    {
        _timer?.Dispose();
        _timer = null;
    }
}

4. Captured Variables in Closures

// BAD: Captures large object
public void ProcessData()
{
    var largeData = LoadLargeDataSet(); // 100MB
    
    Task.Run(() =>
    {
        // Only need one field, but captures entire object
        Console.WriteLine(largeData.Count);
    });
}

// GOOD: Capture only what you need
public void ProcessData()
{
    var largeData = LoadLargeDataSet();
    int count = largeData.Count; // Copy value
    largeData = null; // Allow GC
    
    Task.Run(() =>
    {
        Console.WriteLine(count);
    });
}

5. Unmanaged Resources

// BAD: Native memory leak
public class ImageProcessor
{
    private IntPtr _nativeBuffer;
    
    public ImageProcessor()
    {
        _nativeBuffer = Marshal.AllocHGlobal(1024 * 1024);
    }
    
    // No cleanup!
}

// GOOD: Implement IDisposable
public class ImageProcessor : IDisposable
{
    private IntPtr _nativeBuffer;
    private bool _disposed;
    
    public ImageProcessor()
    {
        _nativeBuffer = Marshal.AllocHGlobal(1024 * 1024);
    }
    
    protected virtual void Dispose(bool disposing)
    {
        if (!_disposed)
        {
            if (disposing)
            {
                // Dispose managed resources
            }
            
            // Free unmanaged resources
            if (_nativeBuffer != IntPtr.Zero)
            {
                Marshal.FreeHGlobal(_nativeBuffer);
                _nativeBuffer = IntPtr.Zero;
            }
            
            _disposed = true;
        }
    }
    
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }
    
    ~ImageProcessor()
    {
        Dispose(false);
    }
}

Identifying Memory Leaks:

1. Using Visual Studio Diagnostic Tools

// Take memory snapshots during execution
// Debug -> Windows -> Show Diagnostic Tools
// Take Snapshot -> Compare snapshots to find growing objects

2. Using dotMemory or ANTS Memory Profiler

# Professional memory profilers
# - Show object retention paths
# - Identify event handler leaks
# - Compare snapshots
# - Find large object heap fragmentation

3. Using PerfView

# Free Microsoft tool
dotnet tool install -g Microsoft.Diagnostics.Tools.dotnet-trace
dotnet-trace collect --process-id  --providers Microsoft-Windows-DotNETRuntime

4. Custom Memory Monitoring

public class MemoryMonitor
{
    private readonly Timer _timer;
    
    public MemoryMonitor()
    {
        _timer = new Timer(CheckMemory, null, 0, 5000);
    }
    
    private void CheckMemory(object state)
    {
        var process = Process.GetCurrentProcess();
        var memoryMB = process.WorkingSet64 / 1024 / 1024;
        
        Console.WriteLine($"Memory Usage: {memoryMB} MB");
        Console.WriteLine($"Gen 0: {GC.CollectionCount(0)}");
        Console.WriteLine($"Gen 1: {GC.CollectionCount(1)}");
        Console.WriteLine($"Gen 2: {GC.CollectionCount(2)}");
        
        // Alert if memory grows continuously
        if (memoryMB > 1000)
        {
            Console.WriteLine("WARNING: High memory usage!");
            
            // Force GC to see if memory is really leaked
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
            
            var afterGC = Process.GetCurrentProcess().WorkingSet64 / 1024 / 1024;
            Console.WriteLine($"After GC: {afterGC} MB");
        }
    }
}

5. Using Memory Dumps

# Create dump file
dotnet-dump collect --process-id 

# Analyze dump
dotnet-dump analyze dump_file.dmp

# Commands in dump analysis
> dumpheap -stat  # Show object statistics
> dumpheap -mt   # Show instances
> gcroot   # Show what keeps object alive
> eeheap -gc  # Show heap stats

6. Application Insights / Logging

public class MemoryMetrics
{
    private readonly ILogger _logger;
    private readonly IMetricsCollector _metrics;
    
    public void LogMemoryUsage()
    {
        var info = GC.GetGCMemoryInfo();
        
        _logger.LogInformation(
            "Memory: Heap={HeapSize}MB, FragmentedBytes={Fragmented}MB",
            info.HeapSizeBytes / 1024 / 1024,
            info.FragmentedBytes / 1024 / 1024);
            
        _metrics.TrackMetric("MemoryUsage", info.HeapSizeBytes);
        _metrics.TrackMetric("Gen2Collections", GC.CollectionCount(2));
    }
}

Detection Patterns:

public class LeakDetector
{
    private long _baselineMemory;
    private int _measurementCount;
    
    public void EstablishBaseline()
    {
        // Force GC to get accurate baseline
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
        
        _baselineMemory = GC.GetTotalMemory(false);
        _measurementCount = 0;
    }
    
    public bool CheckForLeak()
    {
        _measurementCount++;
        
        if (_measurementCount % 10 == 0) // Check every 10 operations
        {
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
            
            var currentMemory = GC.GetTotalMemory(false);
            var growth = currentMemory - _baselineMemory;
            var growthPercentage = (growth * 100.0) / _baselineMemory;
            
            if (growthPercentage > 20) // 20% growth
            {
                Console.WriteLine($"Potential leak detected! Growth: {growthPercentage:F2}%");
                return true;
            }
        }
        
        return false;
    }
}

Best Practices:

  • Always unsubscribe from events
  • Implement IDisposable properly
  • Use weak references for caches
  • Avoid static collections of objects
  • Use using statements for disposable resources
  • Monitor memory in production
  • Profile regularly during development
  • Use memory profilers to find retention paths

Related Resources

Stack Memory:

  • Used for static memory allocation
  • Stores value types, method parameters, and local variables
  • LIFO (Last In, First Out) structure
  • Very fast allocation and deallocation
  • Memory is automatically managed when methods enter/exit scope
  • Limited in size (typically 1MB per thread)
  • Thread-specific - each thread has its own stack
  • No garbage collection needed

Heap Memory:

  • Used for dynamic memory allocation
  • Stores reference types (objects, arrays, strings)
  • Managed by the Garbage Collector
  • Slower allocation and deallocation compared to stack
  • Much larger size (limited by available system memory)
  • Shared across all threads in the application
  • Requires garbage collection to reclaim unused memory
  • Memory fragmentation can occur

Example:

public void Example()
{
    // Stack: value type stored on stack
    int x = 5;
    
    // Stack: variable 'person' (reference) on stack
    // Heap: actual Person object on heap
    Person person = new Person();
    
    // Stack: struct stored on stack
    Point point = new Point(10, 20);
}

Key Differences:

  • Stack is faster but limited in size
  • Heap is slower but can hold larger amounts of data
  • Stack variables are automatically cleaned up
  • Heap requires garbage collection
  • Value types typically go on stack (unless part of reference type)
  • Reference types always go on heap

Profiling Tools:

  1. Visual Studio Profiler

    • CPU Usage
    • Memory Usage
    • Database performance
    • .NET Object Allocation Tracking
  2. dotTrace (JetBrains)

    • Timeline profiling
    • Sampling and tracing
    • Call tree analysis
  3. dotMemory (JetBrains)

    • Memory snapshots
    • Memory leak detection
    • Retention analysis
  4. PerfView

    • ETW-based performance analysis
    • CPU and memory profiling
    • Free Microsoft tool
  5. BenchmarkDotNet

    • Micro-benchmarking library
    • Precise performance measurements

Optimization Strategy:

1. Measure First

// Use BenchmarkDotNet
[Benchmark]
public void MethodToProfile()
{
    // Code to measure
}

2. Identify Bottlenecks

  • CPU hotspots
  • Memory allocations
  • I/O operations
  • Database queries
  • Lock contention

3. Common Optimization Techniques

Reduce Allocations:

// Bad: Creates new string on each call
public string GetFullName(string first, string last)
{
    return first + " " + last;
}

// Better: Use StringBuilder for multiple concatenations
public string GetFullName(string first, string last)
{
    return string.Concat(first, " ", last);
}

// Best for frequent calls: Use Span
public void GetFullName(ReadOnlySpan first, ReadOnlySpan last, Span destination)
{
    first.CopyTo(destination);
    destination[first.Length] = ' ';
    last.CopyTo(destination.Slice(first.Length + 1));
}

Use Value Types When Appropriate:

// Instead of class for small data
public readonly struct Point
{
    public int X { get; }
    public int Y { get; }
}

Async/Await for I/O:

public async Task GetDataAsync()
{
    using var client = new HttpClient();
    return await client.GetStringAsync(url);
}

4. Memory Optimization

  • Use object pooling for frequently created objects
  • Dispose IDisposable objects properly
  • Avoid boxing/unboxing
  • Use Span<T> and Memory<T> for data manipulation
  • Consider using stackalloc for small arrays

5. Monitoring in Production

  • Application Insights
  • Performance counters
  • Custom logging and metrics
  • Health checks

Related Resources

Span<T>:

A ref struct that provides a type-safe and memory-safe representation of a contiguous region of arbitrary memory.

Key Characteristics:

  • Stack-only type (ref struct)
  • Zero allocation
  • Can point to stack memory, managed heap, or native memory
  • Cannot be used in async methods
  • Cannot be stored as a field in regular classes

Example:

// Working with arrays without allocation
public int Sum(Span numbers)
{
    int sum = 0;
    foreach (int num in numbers)
    {
        sum += num;
    }
    return sum;
}

// Can be called with different memory sources
int[] array = {1, 2, 3, 4, 5};
Sum(array); // Works with array
Sum(array.AsSpan(1, 3)); // Works with slice
Span stackArray = stackalloc int[5];
Sum(stackArray); // Works with stack memory

String Manipulation:

public ReadOnlySpan ExtractUsername(string email)
{
    int atIndex = email.IndexOf('@');
    return email.AsSpan(0, atIndex); // No allocation!
}

Memory<T>:

Similar to Span<T> but can be stored on the heap and used in async methods.

Key Characteristics:

  • Can be stored as a field in classes
  • Can be used in async methods
  • Slightly more overhead than Span<T>
  • Provides .Span property to get a Span<T>

Example:

public class BufferProcessor
{
    private Memory _buffer;
    
    public async Task ProcessAsync()
    {
        // Memory can be used across await
        await Task.Delay(100);
        
        // Get Span when doing actual work
        Span span = _buffer.Span;
        ProcessData(span);
    }
    
    private void ProcessData(Span data)
    {
        // Fast processing without allocations
    }
}

When to Use:

Use Span<T> when:

  • Working with buffers in synchronous code
  • Need maximum performance and zero allocations
  • Manipulating strings or arrays without creating copies
  • Working with stack-allocated memory

Use Memory<T> when:

  • Need to store reference to memory as a field
  • Working in async methods
  • Need to pass memory across async boundaries
  • Building APIs that might be used in async contexts

Use ReadOnlySpan<T>/ReadOnlyMemory<T> when:

  • You don't need to modify the data
  • Provides additional safety guarantees

Performance Benefits:

// Traditional approach - allocations!
public string[] SplitData(string data)
{
    return data.Split(','); // Allocates array + strings
}

// Span approach - zero allocations!
public void SplitData(ReadOnlySpan data, Span ranges)
{
    int rangeIndex = 0;
    int start = 0;
    
    for (int i = 0; i < data.Length; i++)
    {
        if (data[i] == ',')
        {
            ranges[rangeIndex++] = start..i;
            start = i + 1;
        }
    }
    ranges[rangeIndex] = start..data.Length;
}