What are memory leaks and how do you identify them in .NET?

5 minadvanced.NETmemory-leakdiagnosticsperformance

Quick Answer

In .NET a 'memory leak' usually means objects that are no longer needed stay referenced, so the GC can't reclaim them and memory grows over time. Common causes: undetached event handlers, static collections/caches that keep growing, captured references in closures or long-lived delegates, and undisposed unmanaged resources. Identify them with profilers (dotnet-counters, dotnet-gcdump, Visual Studio Diagnostic Tools) by comparing heap snapshots and inspecting retention paths.

Detailed Answer

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