Troubleshooting .NET memory leaks – Part 2: Eliminating memory leaks

Dealing with a memory leak in C# can be one of the most frustrating challenges for .NET developers. Your application gradually consumes more memory, response times increase, and eventually the process crashes with an OutOfMemoryException. Understanding how to detect and eliminate these leaks is essential for building stable, production-ready applications.

Note: This is the second part of a series on troubleshooting .NET memory leaks. Please read the first article if you haven't already.

In the first part, you learned how memory leaks occur when a program occupies memory but fails to release it when no longer needed. Opening and closing a program without rebooting causes it to consume more and more memory, leading to slower execution, increased CPU usage, and potential crashes.

The first article explored various tools for analyzing and diagnosing memory consumption in .NET apps and stressed the importance of regularly monitoring application memory usage. This article covers the common sources of memory leaks in C# applications and demonstrates how to fix them with practical code examples.

What is a memory leak in C#?

In a garbage-collected environment like .NET, a memory leak in C# occurs when objects remain referenced even though they are no longer needed by the application. Because the garbage collector (GC) only reclaims memory for objects with no active references, any object that is still referenced — even unintentionally — will persist in memory indefinitely.

There are two primary types of memory leaks in C# and .NET:

  • Managed memory leaks — These happen when managed objects remain referenced but are no longer actively used. Common examples include event handler subscriptions that are never removed, objects stored in static collections, and captured variables in closures. The garbage collector sees these objects as still in use and cannot free them.
  • Unmanaged memory leaks — These occur when your code allocates unmanaged resources (such as file handles, database connections, or native memory through Marshal.AllocHGlobal) without properly releasing them. The garbage collector does not manage unmanaged resources, so you must explicitly free them using the Dispose pattern.

Both types of leaks cause your application's memory footprint to grow over time, eventually leading to degraded performance, higher CPU usage from aggressive garbage collection, and potential OutOfMemoryException crashes.

How to detect memory leaks in C#

Before you can fix a memory leak in C#, you need to identify that one exists and locate its source. Here are the most effective approaches for detecting memory leaks in .NET applications:

Using Visual Studio diagnostic tools

Visual Studio includes a built-in Diagnostic Tools window (Debug > Windows > Show Diagnostic Tools) that displays your application's memory usage in real time during debugging. If the Process Memory graph shows a continuously rising line with frequent garbage collection events (shown as yellow markers) that fail to bring memory back down, you likely have a memory leak.

Using .NET CLI diagnostic tools

The .NET SDK provides powerful command-line tools for diagnosing memory issues in both development and production environments:

  • dotnet-counters — Monitors real-time performance counters including GC heap size, Gen 0/1/2 collection counts, and allocation rates. Use dotnet-counters monitor --process-id <PID> to observe memory trends.
  • dotnet-dump — Captures and analyzes memory dumps from running processes. Use dotnet-dump collect --process-id <PID> to create a snapshot, then dotnet-dump analyze to inspect the heap and identify large or suspicious object allocations.
  • dotnet-trace — Captures diagnostic traces that can be opened in tools like PerfView for deeper analysis of memory allocation patterns and GC behavior.

Using memory profilers

Dedicated memory profilers such as JetBrains dotMemory, SciTech .NET Memory Profiler, and Redgate ANTS Memory Profiler provide the most detailed view of your application's memory usage. They allow you to take heap snapshots, compare them over time, trace object reference paths to GC roots, and identify exactly which objects are leaking and why.

The most effective profiling technique is to take two snapshots — one before and one after an operation — and compare them to see which objects were created but not released.

Monitoring with Site24x7 APM

For production applications, Site24x7 APM Insight with the .NET agent provides continuous monitoring of your application's memory metrics, garbage collection performance, and overall resource consumption. You can configure threshold-based alerts to notify your team when memory usage exceeds normal levels, enabling you to catch leaks before they cause outages.

Fixing memory leaks

The first article used a simple app that intentionally contained a memory leak. The C# program creates an instance of the MyClass class inside an infinite loop in the Main method. The MyClass constructor initializes an array of integers with a specified size but doesn't release the memory when the object is no longer needed. The ~MyClass destructor is called by the garbage collector when collecting objects, but it doesn't release the memory either. The resulting memory leak occurs because the program continuously allocates new memory without freeing the previously allocated memory.

To solve this memory leak, you must release the memory allocated by the MyClass objects when they are no longer needed. One way to do this is to implement the IDisposable interface and provide a Dispose method that releases the resources used by objects.

Here's how to modify the code from the first article to implement IDisposable and free the memory:

class MyClass : IDisposable 
{
private int[] data;

public MyClass(int size)
{
data = new int[size];
}
public void Dispose()
{
data = null;
GC.SuppressFinalize(this);
}

~MyClass()
{
Console.WriteLine("Destructor called");
Dispose();
}
}

Common memory leak causes in C# programs

This section discusses the most common causes of memory leaks in C# programs, with practical code examples showing how to identify and fix each one.

Improperly disposing of objects

The following sample C# code demonstrates how failing to dispose of unmanaged objects properly can lead to memory leaks.

To begin, open Visual Studio and create a new console application with the code below:

using System.Runtime.InteropServices; 
namespace MemoryLeakExample
{
class Program
{
static void Main(string[] args)
{
while (true)
{
var myObj = new MyClass();
Thread.Sleep(100);
}
}
}
class MyClass
{
private readonly IntPtr _bufferPtr;
public MyClass()
{
_bufferPtr = Marshal.AllocHGlobal(4 * 1024 * 1024); // 4 MB
}
}
}

This C# program has a loop that creates a new MyClass object every 100 milliseconds. MyClass has a field called bufferPtr that uses the AllocHGlobal method from the Marshal class to allocate 4 MB of memory each time it creates a new object.

The program runs indefinitely and causes a memory leak because it doesn't release the allocated memory after creating each object. Over time, the program continues to consume memory until it crashes.

To fix this memory leak, you need to release the memory allocated by each MyClass object after it's no longer needed. Start by implementing a method in MyClass that releases the allocated memory using the FreeHGlobal method from the Marshal class. Then, call this method before destroying the MyClass object as follows:

class Program 
{
static void Main(string[] args)
{
while (true)
{
using (var myObj = new MyClass())
{
// do work with myObj
}
Thread.Sleep(100);
}
}
}

class MyClass : IDisposable
{
private readonly IntPtr _bufferPtr;
private bool _disposed = false;

public MyClass()
{
_bufferPtr = Marshal.AllocHGlobal(4 * 1024 * 1024); // 4 MB
}

protected virtual void Dispose(bool disposing)
{
if (_disposed)
return;

if (disposing)
{
// Free any other managed objects here.
}

// Free any unmanaged objects here.
Marshal.FreeHGlobal(_bufferPtr);
_disposed = true;
}

public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}

~MyClass()
{
Dispose(false);
}
}

In C#, improperly disposing of objects can lead to memory leaks, resource depletion, and poor performance. Here's how to avoid these problems:

  • Use the using statement — This C# language feature automatically disposes of objects that implement the IDisposable interface. The using statement ensures that the Dispose method is called even if an exception occurs.
  • Use the Dispose pattern — If you're implementing a class that uses unmanaged resources, you should implement the full Dispose pattern. This includes a protected Dispose(bool) method, a public Dispose() method, and a finalizer as a safety net.
  • Use managed objects whenever possible — The garbage collector automatically disposes of them when they're no longer needed, helping avoid resource leaks and performance issues.

Keeping references to objects unnecessarily

Keeping references to objects unnecessarily in C# programs can lead to memory leaks and slow performance, as demonstrated by the following code:

using System; 
using System.Collections.Generic;

namespace MemoryLeakExample
{
class Program
{
static void Main(string[] args)
{
var myList = new List();
while (true)
{
// populate list with 1000 integers
for (int i = 0; i < 1000; i++)
{
myList.Add(new Product(Guid.NewGuid().ToString(), i));
}
// do something with the list object
Console.WriteLine(myList.Count);
}
}
}

class Product
{
public Product(string sku, decimal price)
{
SKU = sku;
Price = price;
}

public string SKU { get; set; }
public decimal Price { get; set; }
}
}

The C# program creates a list of Product objects called myList and then enters an infinite loop. Within the loop, it adds 1,000 products to myList using a for loop and prints the current count to the console. The program continues to add products to the list indefinitely.

This program demonstrates a potential memory leak because the list continuously grows but doesn't remove any items. As the program continues to run, the list consumes memory until the system runs out of available memory, crashing the program.

Removing objects or data that are no longer needed is essential to preventing memory leaks. In this program, add a line of code after printing the list count that clears the list:

while (true) 
{
// populate list with 1000 integers
for (int i = 0; i < 1000; i++)
{
myList.Add(new Product(Guid.NewGuid().ToString(), i));
}
// do something with the list object
Console.WriteLine(myList.Count);
// clear the list and set its reference to null
myList.Clear();
}

By clearing the contents of the list object, you ensure that the old contents are properly released and their memory is freed up. This prevents the program from keeping unnecessary references to objects, which can cause memory leaks.

Here are some best practices to avoid keeping references to objects unnecessarily:

  • Avoid static variables — Static variables are shared across all instances of a class and persist for the lifetime of the application domain. They can prevent objects from being garbage collected if they hold references to those objects. Use instance variables instead when the data does not need to be shared globally.
  • Use local variables — Local variables are declared within a method and are automatically eligible for garbage collection when the method exits.
  • Use the using statement — This statement helps ensure that objects are disposed of when they're no longer needed.
  • Use the IDisposable interface — This is especially useful if your class uses unmanaged resources. The interface requires implementing the Dispose method to release unmanaged resources.

Incorrect use of event handlers

Event handler subscriptions are one of the most common sources of memory leaks in C#. When an object subscribes to an event, the event publisher holds a strong reference to the subscriber. If the subscriber is never unsubscribed, it cannot be garbage collected, even when it is otherwise unused. Here's a C# code block demonstrating this:

using System; 
namespace MemoryLeakExample
{
class Program
{
static void Main(string[] args)
{
var publisher = new EventPublisher();

while (true)
{
var subscriber = new EventSubscriber(publisher);
// do something with the publisher and subscriber objects
}
}

class EventPublisher
{
public event EventHandler MyEvent;

public void RaiseEvent()
{
MyEvent?.Invoke(this, EventArgs.Empty);
}
}

class EventSubscriber
{
public EventSubscriber(EventPublisher publisher)
{
publisher.MyEvent += OnMyEvent;
}

private void OnMyEvent(object sender, EventArgs e)
{
Console.WriteLine("MyEvent raised");
}
}
}
}

In this code, an EventPublisher object creates an event called MyEvent. An EventSubscriber object subscribes to this event in its constructor by attaching a handler method called OnMyEvent to the MyEvent event. However, the program never detaches the OnMyEvent method from the event, which causes each EventSubscriber object to remain alive even when it's no longer needed.

To fix this issue, implement IDisposable to detach the event handler when the subscriber is no longer needed:

class EventSubscriber : IDisposable 
{
private readonly EventPublisher _publisher;
private bool _disposed = false;
public EventSubscriber(EventPublisher publisher)
{
_publisher = publisher;
_publisher.MyEvent += OnMyEvent;
}

private void OnMyEvent(object sender, EventArgs e)
{
Console.WriteLine("MyEvent raised");
}
protected virtual void Dispose(bool disposing)
{
if (_disposed)
return;

if (disposing)
{
_publisher.MyEvent -= OnMyEvent;
}

_disposed = true;
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}

Now modify the while loop in the Main method to dispose of the subscriber object:

while (true) 
{
using (var subscriber = new EventSubscriber(publisher))
{
// do something with the publisher and subscriber objects
}
}

By detaching the OnMyEvent method from the event in the Dispose method, you release the reference to the EventSubscriber object, freeing up memory. Using the using statement ensures that disposal happens automatically, even if an exception occurs.

The incorrect use of event handlers in C# programs can lead to leaks, performance problems, and unexpected behavior. Here are some best practices to avoid them:

  • Unsubscribe from events when you no longer need them — Failing to unsubscribe from events can lead to memory leaks and performance lags. When an object subscribes to an event, it holds a reference to the event source. If the event source is no longer needed, unsubscribe the object from the event to allow the event source to be garbage collected.
  • Use weak event handlers — Weak event handlers don't hold strong references to the event source. This helps prevent memory leaks and allows the event source to be garbage collected when no longer needed.
  • Avoid using static event handlers — Static event handlers are associated with a static class or a static field. They can prevent objects from being garbage collected if they hold references to those objects. Instead, consider using instance event handlers.

Static variables and collections

Static variables persist for the entire lifetime of the application domain. Any object referenced by a static variable is considered a GC root, meaning the garbage collector will never collect it. This makes static collections a particularly dangerous source of memory leaks in C#:

class SessionTracker 
{
private static List<SessionData> _sessions = new List<SessionData>();

public static void TrackSession(SessionData session)
{
_sessions.Add(session);
// Sessions are never removed from the list
}
}

In this example, the static _sessions list grows indefinitely because sessions are added but never removed. Since it's a static field, the list itself and all the SessionData objects it references will never be garbage collected.

To fix this, implement a mechanism to remove stale entries or use a bounded collection:

class SessionTracker 
{
private static readonly ConcurrentDictionary<string, SessionData> _sessions
= new ConcurrentDictionary<string, SessionData>();

public static void TrackSession(string id, SessionData session)
{
_sessions[id] = session;
}

public static void RemoveSession(string id)
{
_sessions.TryRemove(id, out _);
}
}

Captured variables in closures

When a lambda expression or anonymous method captures a local variable, it creates a closure that holds a reference to the enclosing object. This can cause memory leaks in C# if the closure outlives the expected lifetime of the captured objects:

public class DataProcessor 
{
private byte[] _largeBuffer = new byte[10 * 1024 * 1024]; // 10 MB

public Action GetCallback(EventManager manager)
{
// The lambda captures 'this', keeping _largeBuffer alive
manager.OnDataReady += (s, e) => ProcessData(_largeBuffer);
return () => Console.WriteLine("Callback");
}

private void ProcessData(byte[] data) { /* ... */ }
}

In this example, the lambda subscribes to an event and captures a reference to this (via _largeBuffer). As long as the EventManager holds the event subscription, the entire DataProcessor object (including its 10 MB buffer) cannot be garbage collected. To avoid this, unsubscribe from the event when done or use weak references where appropriate.

Improper caching

Improper caching can cause memory leaks in a C# program because objects that are cached and no longer needed can remain in memory, consuming resources and leading to the exhaustion of available memory over time. This process happens when the cache is implemented without considering the objects' lifespan or setting a maximum size.

Here is an example of a C# program that uses a cache but does not properly manage its lifespan:

using System; 
using System.Collections.Generic;
class Cache
{
private static Dictionary<int, object> _cache = new Dictionary<int, object>();

public static void Add(int key, object value)
{
_cache.Add(key, value);
}

public static object Get(int key)
{
return _cache[key];
}
}

class Program
{
static void Main(string[] args)
{
for (int i = 0; i < 1000000; i++)
{
Cache.Add(i, new object());
}

Console.WriteLine("Cache populated");

Console.ReadLine();
}
}

This program creates a cache with one million objects and adds them to the cache. However, since the cache is never cleared, all one million objects remain in memory even after they are no longer needed.

To solve those memory leaks, you must implement the cache in a way that allows for the lifespan of cached objects to be managed. You can use a cache that automatically removes objects after a certain period or when the cache becomes too large. Here is how to implement it:

  • Add a CachedItem class that contains the cached object and its expiration time.
    private sealed class CachedItem 
    {
    public object Value { get; set; }
    public DateTime Expiration { get; set; }
    }
  • Inside the Cache class, instead of using a Dictionary<int, object> to store the cached items, use a Dictionary<int, CachedItem> type to store the cached items with additional information such as expiration time.
    private static readonly Dictionary<int, CachedItem> _cache =  new Dictionary<int, CachedItem>();
                      
  • Modify the Add method to store the cached item as a CachedItem with a specified lifespan.
    public static void Add(int key, object value, TimeSpan lifespan) 
    {
    _cache.Add(key, new CachedItem { Value = value, Expiration = DateTime.Now + lifespan });
    }
  • Modify the Get method to check the expiration time of the cached item and remove it from the cache if it has expired.
    public static object? Get(int key) 
    {
    if (!_cache.ContainsKey(key))
    {
    return null;
    }

    CachedItem item = _cache[key];

    if (item.Expiration < DateTime.Now)
    {
    _cache.Remove(key);
    return null;
    }
    return item.Value;
    }
  • Inside the Program class, modify the call to the Cache.Add method to pass the expiration argument:
    Cache.Add(i, new object(), TimeSpan.FromMinutes(10));
                      

In this modified program, we create a cache with one million objects and add them with a lifespan of 10 minutes using the Add method. The Get method checks the expiration time of each cached object and removes it from the cache if it has expired. This ensures that objects no longer needed are properly removed from memory, preventing memory leaks.

Here are some best practices for avoiding improper caching in C# programs:

  • Understand the lifespan of cached objects — Before caching an object, consider its lifespan and how long it should remain in memory. Determine whether it's appropriate to cache the object at all or if it would be better to recreate it each time it's needed.
  • Use expiration policies — When caching an object, set an expiration time or policy so that it can be removed from the cache automatically when it's no longer needed. You can use built-in expiration policies in C# caching frameworks such as the MemoryCache class in the System.Runtime.Caching namespace or create custom expiration policies.
  • Monitor cache size — Monitor the size of the cache and ensure that it doesn't grow too large. Large caches can cause performance issues and memory leaks, especially if the cache contains many large objects. Set a maximum cache size and implement eviction policies to keep the cache within bounds.

Large object graphs

To visualize how large object graphs can lead to memory leaks, copy the following code to a Console application within Visual Studio:

using System; 
using System.Collections.Generic;
namespace MemoryLeakExample
{
class Program
{
static void Main(string[] args)
{
var rootNode = new TreeNode();
while (true)
{
// create a new subtree of 10000 nodes
var newNode = new TreeNode();
for (int i = 0; i < 10000; i++)
{
var childNode = new TreeNode();
newNode.AddChild(childNode);
}
rootNode.AddChild(newNode);
}
}
}

class TreeNode
{
private readonly List<TreeNode> _children = new List<TreeNode>();
public void AddChild(TreeNode child)
{
_children.Add(child);
}
}
}

This code creates a TreeNode object as the root node of a tree structure. The program then repeatedly creates new subtrees of 10,000 nodes and adds them as children of the root node. However, the program never removes the old subtrees, so it continues using up memory.

To fix this issue, modify the TreeNode class to expose the child nodes as a public property, then create a RemoveChildAt method:

class TreeNode 
{
private readonly List<TreeNode> _children = new List<TreeNode>();
public IReadOnlyList<TreeNode> Children => _children;
public void AddChild(TreeNode child)
{
_children.Add(child);
}
public void RemoveChildAt(int index)
{
_children.RemoveAt(index);
}
}

Now modify the while loop within the Main method to remove the old subtrees after you're done using them:

while (true) 
{
// create a new subtree of 10000 nodes
var newNode = new TreeNode();
for (int i = 0; i < 10000; i++)
{
var childNode = new TreeNode();
newNode.AddChild(childNode);
}
rootNode.AddChild(newNode);
// remove the old subtrees to free up memory
if (rootNode.Children.Count > 10)
{
rootNode.RemoveChildAt(0);
}
}

By removing the old subtrees, you ensure that the program doesn't consume unbounded memory. You also prevent memory leaks by removing the old subtrees so the program doesn't keep unnecessary references to old objects.

Here are some best practices for avoiding large object graphs in C# programs:

  • Use lazy loading — Lazy loading is a technique where objects aren't loaded until needed, reducing the initial memory footprint and improving performance.
  • Use weak references — Weak references (via WeakReference<T>) don't prevent objects from being garbage collected. They are useful for caches and lookup tables where you want to reference an object without keeping it alive.
  • Avoid circular references — Circular references occur when objects reference each other. While the .NET garbage collector can handle most circular references in managed code, they make debugging harder and can cause issues with manual resource management.

How to prevent memory leaks in C#

Beyond fixing individual memory leaks, adopting these preventive practices will help you build C# applications that are resilient to memory issues:

  • Always dispose of IDisposable objects — Use the using statement or using declaration (C# 8.0+) for any object that implements IDisposable. This covers streams, database connections, HTTP clients, and many other framework classes.
  • Unsubscribe from events consistently — Establish a pattern in your codebase where every event subscription (+=) has a corresponding unsubscription (-=). Consider implementing IDisposable on any class that subscribes to external events.
  • Be cautious with static fields — Static variables are GC roots and persist for the application's lifetime. Avoid storing large collections or frequently changing data in static fields. When static collections are necessary, implement size limits and cleanup logic.
  • Add memory telemetry — Use Process.GetCurrentProcess().PrivateMemorySize64 or the PerformanceCounter class to log memory metrics periodically. Monitoring tools like Site24x7 .NET monitoring provide continuous visibility into memory trends across your production environment.
  • Test for memory leaks — Write unit tests that verify objects are properly collected after use. You can use WeakReference to check whether an object has been collected after forcing garbage collection with GC.Collect().
  • Profile regularly during development — Run memory profiling sessions as part of your development workflow, not just when problems arise. Taking snapshots before and after operations helps catch leaks early, when they are easiest to fix.

Conclusion

This article concludes the troubleshooting .NET memory leaks series. A memory leak in C# can stem from many sources — improper disposal of unmanaged resources, forgotten event subscriptions, static variables holding references, unbounded caches, captured variables in closures, and large object graphs that grow without bounds.

The key to managing memory leaks in C# effectively is a combination of detection, fixing, and prevention. Use diagnostic tools like Visual Studio's memory profiler, the dotnet-dump and dotnet-counters CLI tools, and production monitoring solutions like Site24x7 APM Insight to identify leaks early. Apply the Dispose pattern and using statement consistently, unsubscribe from events, and be mindful of how references flow through your application.

By following the practices outlined in this guide, you can significantly reduce the likelihood of memory leaks and improve the performance, stability, and reliability of your .NET applications.

FAQs

1. What is a memory leak in C#?

A memory leak in C# occurs when your application allocates memory for objects that are no longer needed but cannot be reclaimed by the garbage collector. This happens because references to those objects still exist, preventing garbage collection. Over time, leaked memory accumulates, degrading performance and potentially causing OutOfMemoryException crashes.

Site24x7 APM Insight monitors .NET application performance using the .NET agent, tracking memory allocation, garbage collection metrics, response times, and identifying methods that cause high memory usage. It provides real-time visibility into your application's memory footprint so you can spot abnormal growth patterns early.

Yes, you can set threshold-based alerts on memory usage metrics in Site24x7 to be notified instantly before a memory leak leads to application crashes or performance degradation.

The most common causes include not disposing of unmanaged resources properly, failing to unsubscribe from event handlers, holding unnecessary object references in static variables or collections, improper caching without expiration policies, captured variables in closures, and long-running threads that retain object references.

No. While the garbage collector automatically manages memory for managed objects, it cannot collect objects that are still referenced. Memory leaks in C# typically occur when objects remain referenced unintentionally, such as through event subscriptions, static fields, or closures. Unmanaged resources like file handles and database connections also require explicit disposal.

Implement the IDisposable interface in classes that hold unmanaged resources. Use the using statement to ensure Dispose is called automatically. The Dispose pattern includes a protected virtual Dispose(bool) method, a public Dispose() method that calls GC.SuppressFinalize, and a finalizer as a safety net.

Was this article helpful?
Monitor your applications with ease

Identify and eliminate bottlenecks in your application for optimized performance.

Related Articles