What are memory leaks and how do you identify them in .NET?
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
usingstatements for disposable resources - Monitor memory in production
- Profile regularly during development
- Use memory profilers to find retention paths