Even with a garbage collector, C# applications can suffer from memory leaks and performance issues that are hard to diagnose. We've seen teams spend weeks chasing mysterious OutOfMemoryExceptions or CPU spikes caused by the garbage collector running too frequently. This guide walks through the most common memory management gotchas we encounter in production systems, with concrete steps to avoid them.
Why Memory Management Still Matters in Managed Code
The .NET garbage collector (GC) does a remarkable job of automatically reclaiming memory, but it's not a silver bullet. Many developers assume that because they don't call free() or delete(), they don't need to think about memory at all. That assumption leads to subtle bugs that only surface under load. For example, a web API that leaks a few hundred bytes per request might run fine in testing but crash after a few hours in production. Understanding when the GC can't help you is the first step to writing robust C# code.
Memory leaks in managed code usually fall into one of two categories: objects that are still referenced but no longer needed, or objects that survive too many GC generations because they're pinned or large. Both scenarios increase memory pressure and force the GC to work harder, degrading throughput. In this guide, we focus on the patterns that cause these problems and how to fix them.
Who Should Read This
This article is for C# developers working on server-side applications, desktop clients, or games who have encountered unexplained memory growth or performance issues. We assume you're familiar with basic GC concepts like generations and finalization, but we'll explain the gotchas in plain language.
Core Idea: The GC is Your Friend, But Not Your Cleaner
The .NET GC uses a generational approach: new objects are allocated in Gen 0, and if they survive a collection, they're promoted to Gen 1 and then Gen 2. The GC collects Gen 0 frequently and Gen 2 rarely, because scanning the entire heap is expensive. The key insight is that the GC only reclaims memory for objects that are unreachable from application roots (static fields, local variables on the stack, CPU registers, and GC handles). If an object is still referenced by a root, it's considered alive and will never be collected, even if you never use it again.
This is where most gotchas originate. You might create an object, attach it to an event, and then lose the reference to the subscriber. The subscriber remains rooted through the event delegate, preventing collection. Similarly, forgetting to dispose of unmanaged resources (file handles, database connections, sockets) can cause native memory leaks that the GC can't fix. The GC only manages managed memory; unmanaged resources require explicit cleanup via IDisposable.
The Two Kinds of Leaks
We distinguish between managed leaks (unintentional object retention) and native leaks (unmanaged memory not freed). Both can crash your application. Managed leaks cause the GC to hold onto memory longer, increasing gen-2 heap size and eventually causing OutOfMemoryException. Native leaks consume OS memory directly, often leading to process termination. The fixes are different: managed leaks require breaking references; native leaks require proper disposal.
How the Garbage Collector Works Under the Hood
To understand the gotchas, you need a mental model of the GC's behavior. When the GC runs a collection, it suspends all managed threads (a 'stop-the-world' event), walks the root references to build a graph of live objects, and then compacts the heap by moving surviving objects together. Compaction is expensive but reduces fragmentation. However, there are cases where compaction cannot happen: for objects that are pinned (e.g., via fixed statement or GCHandle.Alloc with GCHandleType.Pinned). Pinned objects fragment the heap and force the GC to do more work.
Another important detail is the finalization queue. Objects with finalizers (destructors) are not collected immediately; they're placed on the finalization queue and the finalizer thread runs them asynchronously. This delays memory reclamation and can cause objects to survive an extra generation, increasing memory pressure. If the finalizer thread is blocked (e.g., by a deadlock), finalizable objects pile up and the application leaks memory.
Large Object Heap (LOH) and Fragmentation
Objects larger than 85,000 bytes go to the Large Object Heap (LOH), which is not compacted by default (in .NET Framework; .NET Core+ does compact LOH on demand). Frequent allocation and deallocation of large objects can lead to LOH fragmentation, where free blocks are too small to satisfy new allocations, causing OutOfMemoryException even though total free memory is sufficient. This is a common gotcha in applications that allocate large buffers or cache large collections.
Worked Example: Event Handler Leaks and Weak References
Let's walk through a concrete scenario. Imagine a desktop application with a MainWindow that subscribes to a static event:
public static class EventBus
{
public static event EventHandler SomethingHappened;
}
public class MainWindow : Window
{
public MainWindow()
{
EventBus.SomethingHappened += OnSomethingHappened;
}
private void OnSomethingHappened(object sender, EventArgs e) { }
}When the user closes MainWindow, the window object is no longer needed. But because it subscribed to EventBus.SomethingHappened, the event delegate holds a reference to the MainWindow instance. The GC sees that MainWindow is reachable through the event, so it's never collected. The window's memory (including any child controls, bindings, and associated objects) remains in memory indefinitely. If the user opens and closes many windows, memory grows without bound.
The Fix: Unsubscribe or Use Weak Events
The simplest fix is to unsubscribe when the window closes:
protected override void OnClosed(EventArgs e)
{
EventBus.SomethingHappened -= OnSomethingHappened;
base.OnClosed(e);
}Alternatively, use a weak event pattern (e.g., WeakEventManager in WPF, or a custom WeakReference-based delegate). Weak events allow the subscriber to be collected even if the event source still holds a reference. However, weak events have their own gotchas: they rely on finalization to clean up, which can cause delays and thread-safety issues.
Edge Cases and Exceptions: When the Usual Advice Fails
Even experienced developers can be tripped up by edge cases. Here are three that often surprise teams.
1. Captured Variables in Lambdas and Closures
When you create a lambda that captures a local variable, the compiler generates a closure class that holds references to the captured variables. If the lambda is passed to a long-lived delegate (e.g., a static event or a timer), the closure keeps all captured objects alive. For example:
public class DataCache
{
private byte[] _largeBuffer = new byte[100_000];
public void StartTimer()
{
Timer t = new Timer(_ => { Console.WriteLine(_largeBuffer.Length); }, null, 0, 1000);
}
}The timer callback captures _largeBuffer through the closure, so the DataCache instance is rooted as long as the timer runs. If you forget to dispose the timer, the entire object graph remains alive.
2. Async/Await and SynchronizationContext
In UI applications, the SynchronizationContext posts continuations to the UI thread. If a continuation captures a reference to a UI element (e.g., a button), and the continuation is never executed (e.g., because the task never completes), the UI element stays alive. This is a common source of leaks in Windows Forms and WPF applications. The fix is to use ConfigureAwait(false) where appropriate, or to cancel tasks when the UI element is disposed.
3. Pinned Objects and Interop
When calling unmanaged code, you might pin a managed array to get a pointer. If you forget to unpin (or the pinning lasts longer than needed), the GC cannot compact the heap around that object. Over time, pinned objects cause fragmentation, especially in the LOH. Use GCHandleType.Pinned sparingly, and prefer Marshal.AllocHGlobal for long-lived buffers.
Limits of the Approach: When GC Tuning Isn't Enough
Even with perfect code, the GC has inherent trade-offs. For ultra-low-latency applications (e.g., real-time trading systems, game engines), GC pauses can be unacceptable. In those cases, you might need to use GC.TryStartNoGCRegion to suppress collections during critical paths, or allocate from a pre-allocated pool to avoid GC entirely. Another limit is that the GC cannot reclaim memory that is pinned or referenced by native code. If you have a native library that holds a reference to a managed object via a GC handle, that object is effectively immortal.
Memory analysis tools like dotMemory, PerfView, or the built-in Visual Studio Diagnostic Tools are essential for identifying leaks. But even the best tools can't fix design issues. For example, if your architecture uses a singleton cache that grows unbounded, no amount of GC tuning will prevent eventual exhaustion. You need to implement eviction policies (e.g., MemoryCache with expiration, or WeakReference-based caches).
When to Avoid Weak References
Weak references seem like a panacea for leaks, but they have drawbacks. They rely on the GC to collect the target, which is non-deterministic. If you need the object to remain alive until you're done with it, a weak reference might cause it to be collected prematurely. Also, weak references consume memory themselves (a small object per reference) and require null checks. Use them only for caches or event patterns where the subscriber's lifetime is independent of the publisher.
Reader FAQ: Common Questions About C# Memory Management
Q: Does calling GC.Collect() manually help?
A: Almost never. Forcing a collection can improve responsiveness in rare cases (e.g., after a large allocation that you know is temporary), but it usually hurts performance by promoting objects prematurely and causing unnecessary collections. The GC tunes itself based on allocation patterns; manual calls disrupt that tuning.
Q: How do I detect a memory leak?
A: Use a memory profiler to take snapshots at two points in time (e.g., before and after a user action). Compare the heap to see which objects are accumulating. Look for types that shouldn't be there, like old UI windows or large collections. Also monitor the Gen 2 heap size over time; a steady increase indicates a leak.
Q: What's the difference between a memory leak and high memory usage?
A: A leak is memory that is no longer needed but still referenced. High memory usage can be legitimate (e.g., a cache that is actively used). The key is whether memory grows without bound when the application is idle. If it plateaus, it's likely not a leak.
Q: Should I implement IDisposable for all my classes?
A: Only if your class directly owns unmanaged resources (file handles, sockets, etc.) or contains disposable fields. Implementing IDisposable for managed-only classes adds complexity and can lead to double-dispose bugs. Use the Dispose pattern correctly: implement both Dispose() and a finalizer only if you have unmanaged resources.
Q: Can the GC ever cause a deadlock?
A: Yes, in rare cases. If a finalizer thread is blocked (e.g., waiting on a lock held by a thread that is suspended during GC), the finalization queue stops processing, and objects with finalizers accumulate. This is known as a 'finalizer deadlock'. Avoid blocking in finalizers and use SafeHandle for interop.
Practical Takeaways: Five Steps to Leak-Free Code
We've covered a lot of ground. Here are the concrete actions you can take starting today.
- Unsubscribe from events and delegates when the subscriber is no longer needed. Use weak event patterns for long-lived publishers.
- Dispose of IDisposable objects promptly, ideally with a
usingstatement. For class fields, implementIDisposableand call dispose in aDispose(bool)method. - Avoid capturing large objects in closures that outlive their context. Extract only the data you need into a local variable before creating the lambda.
- Monitor Gen 2 and LOH sizes in production using performance counters (
.NET CLR Memory). Set alerts for sustained growth. - Profile early and often. Integrate memory profiling into your CI pipeline or at least run it before major releases. A 30-minute profiling session can save days of debugging.
Memory management in C# is a skill that improves with deliberate practice. Start by fixing the event handler leaks in your current project — they're the most common and easiest to resolve. Then move on to async patterns and interop. Your application will thank you with lower latency and fewer crashes.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!