A C# memory leak happens when your application keeps holding onto objects it no longer needs, preventing the garbage collector from reclaiming that memory.
Here’s how we’ll hunt it down and fix it:
Diagnosing the Leak
First, we need to see the leak in action.
-
Reproduce the Leak: Run your application under conditions that you suspect trigger the leak. Let it run for a while, ideally long enough for the memory usage to climb significantly.
-
Capture a Heap Dump: We’ll use
dotnet-gcdumpto grab a snapshot of the managed heap.dotnet-gcdump collect -p <PID>Replace
<PID>with the process ID of your running C# application. This command creates a.gcdumpfile. -
Analyze the Heap Dump: Open the
.gcdumpfile in a memory analysis tool like Visual Studio’s Memory Usage tool or PerfView. Look for objects that are unexpectedly growing in count or size over time. Pay attention to the "Retained Size" – this is the memory that would be freed if the object itself was collected. -
Trace for Performance Bottlenecks: While the heap dump shows what’s there,
dotnet-tracecan show why it’s getting there. Capture a trace while the leak is occurring.dotnet trace collect -p <PID> --providers Microsoft-Windows-DotNETRuntime --format NetTraceThis creates a
.nettracefile. Open this in Visual Studio’s Performance Profiler. Examine the "GC Allocations" and "CPU Usage" to see which methods are allocating the most memory.
Common Causes and Fixes
Here are the usual suspects for C# memory leaks:
-
Static Collections: A
staticlist, dictionary, or other collection that keeps growing indefinitely without any mechanism to remove items.- Diagnosis: In your heap dump analysis, look for large collections with a high retained size, especially if they are static fields.
- Fix: Implement a bounded collection (e.g., using a
ConcurrentQueuewith aTryDequeuein a loop) or explicitly clear/remove items from the static collection when they are no longer needed. For example, if you have astatic List<MyObject> _cache;, ensure you have_cache.Clear()or_cache.Remove(item)calls. - Why it works: By removing references to objects from the static collection, you allow the garbage collector to reclaim their memory.
-
Event Handlers Not Unsubscribed: When an object subscribes to an event on another object, and the subscribing object is no longer needed but the event source lives on, the subscription keeps the subscriber alive. This is especially common with UI elements or long-lived services.
- Diagnosis: Look for instances of objects that should have been garbage collected, but are still referenced by event subscriptions. The "GC Root" analysis in your memory tool will often show an event handler as the GC root.
- Fix: Always unsubscribe from events when the subscribing object is disposed or no longer needs to listen. Use
+=for subscribing and-=for unsubscribing.// In your object's Dispose method or when it's no longer needed: myEventSource.MyEvent -= MyEventHandler; - Why it works: Removing the event subscription breaks the reference chain from the event source to the subscriber, allowing the subscriber to be collected.
-
Closures Capturing Large Objects: Lambda expressions and anonymous methods can capture variables from their surrounding scope. If a closure lives longer than expected (e.g., it’s attached to a long-lived object or event), it can keep the captured variables alive.
- Diagnosis: Examine the GC roots in your heap dump. You might see a closure (often represented by an anonymous delegate type) holding a reference to a large object or collection that shouldn’t be alive.
- Fix: Be mindful of what variables are captured. If possible, pass only necessary data as parameters to the lambda or ensure the closure itself has a limited lifetime. Consider creating local copies of large captured variables inside the lambda if they are only used locally.
// Potential leak: var largeObject = new byte[1024 * 1024]; var longLivedDelegate = new Action(() => { Console.WriteLine(largeObject.Length); }); // largeObject is captured // Better: var largeObject = new byte[1024 * 1024]; var localCopy = largeObject; // Copy reference to a local variable var shortLivedDelegate = new Action(() => { Console.WriteLine(localCopy.Length); }); // localCopy is captured, but it's just a reference. If localCopy goes out of scope, the large object can be collected. - Why it works: By limiting the scope of captured variables or ensuring the closure itself is short-lived, you prevent unintended long-term object retention.
-
Unmanaged Resources Not Disposed: While not strictly a managed memory leak, unmanaged resources (like file handles, network connections, or GDI objects) held by managed objects can prevent the underlying system resources from being released, leading to overall system exhaustion. If these objects also hold managed memory, that memory also stays alive.
- Diagnosis: Use
dotnet-tracewith theMicrosoft-DotNETRuntimeprovider and look for high counts ofSystem.Runtime.InteropServices.SafeHandlederivatives or custom unmanaged resource wrappers. Also, check Windows Task Manager for handles. - Fix: Implement the
IDisposablepattern correctly for classes that manage unmanaged resources. EnsureDispose()is called, ideally via ausingstatement or atry-finallyblock.public class MyResourceWrapper : IDisposable { private IntPtr _handle; // Example unmanaged handle public MyResourceWrapper() { // Allocate unmanaged resource and get _handle } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (_handle != IntPtr.Zero) { // Release unmanaged resource associated with _handle _handle = IntPtr.Zero; } if (disposing) { // Dispose managed resources here if any } } ~MyResourceWrapper() { Dispose(false); } } - Why it works: Explicitly releasing unmanaged resources frees them up for reuse by the operating system and breaks the chain of managed objects holding onto them.
- Diagnosis: Use
-
Caching Without Eviction Policies: Implementing custom caching mechanisms without a strategy to remove old or unused items can lead to unbounded growth.
- Diagnosis: Similar to static collections, look for cache-like data structures (often dictionaries or specialized cache classes) in your heap dump that are growing excessively.
- Fix: Use a cache with an eviction policy (e.g., Least Recently Used - LRU, Time-based expiration). Libraries like
Microsoft.Extensions.Caching.Memoryprovide such features. If building your own, implement logic to remove items based on size, age, or access frequency. - Why it works: Eviction policies ensure that the cache doesn’t grow indefinitely, removing stale or less-used items to make room for new ones and allowing old items to be garbage collected.
-
Circular References (Less Common with Modern GC): While the .NET garbage collector is very good at handling circular references, in some complex scenarios, especially involving finalizers or specific interop patterns, they can contribute to leaks if not managed.
- Diagnosis: This is harder to spot directly. It usually manifests as objects with non-zero retained size that should be collected but aren’t, and their GC roots are complex, potentially involving multiple objects referencing each other.
- Fix: Break the cycle explicitly when objects are no longer needed. If using finalizers, be extremely careful and consider
IDisposableas a primary mechanism. In rare interop cases,IDisposablefor both managed and unmanaged sides is crucial. - Why it works: Breaking the cycle ensures at least one object in the cycle becomes reachable from a GC root, allowing the entire cycle to be collected.
Next Steps
Once you’ve fixed the identified leaks, the next common problem you’ll encounter is performance degradation due to excessive garbage collection cycles, which often requires a different profiling approach.