Every C# developer knows the garbage collector (GC) is supposed to manage memory for us. Yet in production, mysterious pauses, growing memory footprints, and sudden OutOfMemoryExceptions still happen. The problem isn't the GC itself—it's how our code interacts with it. This guide focuses on six hidden pitfalls that cause real-world performance issues and shows practical fixes you can apply today.
We assume you're already comfortable with basic C# and .NET concepts. Our goal is to move from 'the GC handles it' to understanding exactly when it doesn't, and what to do about it.
1. The Real Cost of Allocations: Why Small Objects Matter
Many teams think only large objects or obvious leaks cause GC problems. In reality, the sheer number of small allocations—strings, lambdas, temporary objects—can trigger frequent Gen0 collections that steal CPU time. A typical web request might allocate thousands of small objects, and if each collection pauses the thread for a few milliseconds, latency adds up.
The GC in .NET uses a generational model: short-lived objects in Gen0 are collected frequently; objects that survive move to Gen1 and then Gen2, where collections are rarer but more expensive. The key insight is that reducing allocation volume directly reduces collection frequency and pause times. This is not about avoiding all allocations—it's about avoiding unnecessary ones.
Common patterns that create hidden allocations
String concatenation in loops is a classic example. Each + operation creates a new string, discarding the old one. In a tight loop, this floods Gen0. Use StringBuilder or string.Create for repeated concatenation. Another pattern is LINQ queries that allocate enumerators and temporary objects; for performance-critical paths, consider manual loops or ArrayPool.
How to measure allocation pressure
Use the .NET Memory Profiler (dotMemory) or PerfView to track allocation rates. Look for methods that allocate more than a few kilobytes per call. A common rule of thumb: if a method is called thousands of times per second and allocates even 100 bytes each time, the total allocation rate can exceed 10 MB/s, forcing frequent collections.
2. The Large Object Heap: When Size Matters
Objects larger than 85,000 bytes go to the Large Object Heap (LOH). Unlike the small object heap, the LOH is not compacted by default—meaning freed memory leaves holes. Over time, fragmentation can cause OutOfMemoryException even when total free space is sufficient. This is a classic gotcha: you allocate a large buffer, use it, then release it; the next allocation of a different size may fail because no contiguous block exists.
Why fragmentation happens
Consider a server that processes variable-sized requests. It allocates a 100 KB array for one request, then a 200 KB array for the next. After the first is freed, a 150 KB allocation may not fit in the gap. The LOH has no compaction, so the memory is wasted unless a matching size comes along. Over time, this leads to fragmentation.
Practical fixes for LOH fragmentation
Use ArrayPool<byte> for temporary buffers—it reuses arrays of the same size, reducing LOH allocations. If you must allocate large objects, consider pooling them manually. Another approach is to allocate a single large buffer and partition it yourself, avoiding the LOH entirely for multiple smaller allocations. In .NET 5+, you can enable LOH compaction via GCSettings.LargeObjectHeapCompactionMode, but this is a stopgap; pooling is more efficient.
3. Finalizers and IDisposable: The Hidden Delay
Objects with finalizers (destructors) are not collected immediately. The GC places them on a finalization queue, and a dedicated thread runs finalizers later. This delays memory reclamation and can cause objects to survive into higher generations, making collections more expensive. Worse, a blocked finalizer thread can bring the application to a halt.
The correct dispose pattern
Implement IDisposable for any class that owns unmanaged resources (file handles, sockets, database connections). The standard pattern includes a Dispose(bool disposing) method and a finalizer only as a safety net. In practice, if you always call Dispose (or use using statements), the finalizer is unnecessary and should be suppressed with GC.SuppressFinalize(this).
Common mistake: forgetting to suppress finalization
Even if you implement IDisposable correctly, if you don't call GC.SuppressFinalize, the object still gets finalized later. This adds overhead and delays GC. Always call GC.SuppressFinalize at the end of your Dispose method. Use using blocks to ensure disposal even on exceptions.
When finalizers are actually needed
Only implement a finalizer when your class directly wraps an unmanaged resource that cannot be released via SafeHandle. In most cases, SafeHandle or SafeBuffer handles the finalization for you, so you don't need to write one.
4. Event Handlers and Memory Leaks: The Subscription That Never Ends
A common hidden leak is subscribing to an event on a long-lived object from a short-lived object. The long-lived object holds a reference to the short-lived subscriber via the event delegate, preventing the subscriber from being collected. This is especially sneaky because the subscriber appears to have no references, yet it survives.
Example: UI event handlers in desktop applications
In a WPF app, a view model subscribes to a static or long-lived service's PropertyChanged event. If the view model goes out of scope but the service still holds the delegate, the view model leaks. Over time, memory grows until the app becomes sluggish.
Fix: unsubscribe or use weak events
Always unsubscribe from events when the subscriber is no longer needed. In C#, use the -= operator with the same delegate instance. For patterns where the subscriber lifetime is hard to manage, use the WeakEvent pattern (or WeakEventManager in WPF) so the event source does not hold a strong reference. Another option is to use reactive extensions (Rx) with disposables that clean up subscriptions.
Diagnosing event leaks
Use a memory profiler to take snapshots and compare reference graphs. Look for objects that should be garbage-collected but are still reachable. Event handlers often show up as unexpected references from long-lived objects.
5. Struct vs. Class: Performance Implications Beyond Semantics
Choosing between struct and class is not just about value vs. reference semantics—it directly impacts GC pressure. Structs are allocated on the stack (or inline in arrays) and do not trigger GC collections when they go out of scope. However, boxing a struct (e.g., assigning to object or IComparable) creates a heap allocation. Also, large structs (over 16 bytes) can be expensive to copy.
When to use structs
Use structs for small, immutable data types that are frequently allocated and short-lived. For example, a Point or Color struct avoids heap allocation. In hot paths (e.g., game loops or high-frequency trading), replacing classes with structs can dramatically reduce GC pauses.
Pitfalls: boxing and mutability
Every time you pass a struct to a method that expects an interface, it gets boxed. This allocates a heap object that must be collected. Avoid this by using generic methods with constraints (where T : IComparable<T>) or by avoiding interfaces on hot paths. Also, mutable structs are dangerous—they are copied on assignment, leading to confusion and performance hits. Prefer immutable structs with readonly fields.
Decision checklist
- Is the type small (≤16 bytes)? Yes → consider struct.
- Is it logically a single value? Yes → struct.
- Will it be stored in arrays? Struct arrays are contiguous and cache-friendly.
- Will it be boxed frequently? If yes, class may be better.
6. When to Call GC.Collect (And Why You Almost Never Should)
Many developers, when faced with memory pressure, are tempted to call GC.Collect() manually. This is almost always a bad idea. Forcing a collection can promote objects to higher generations prematurely, causing them to survive longer and increasing the cost of future collections. It can also degrade performance by triggering full collections at inopportune times.
Rare legitimate uses
One scenario where manual collection might be acceptable is after a large, one-time allocation that you know is no longer needed—for example, after loading a large configuration file. Even then, it's often better to let the GC decide. Another case is in testing, to force cleanup before measuring memory. In production, rely on the GC's heuristics.
Alternatives to manual collection
Use GC.AddMemoryPressure and GC.RemoveMemoryPressure for unmanaged allocations, so the GC knows to run more frequently. Use MemoryFailPoint to check for available memory before large allocations. If you have a known memory spike, consider using a separate AppDomain (or process) that can be unloaded.
What to do instead
Focus on reducing allocations and fixing leaks. Profile your application to identify the source of memory pressure. If you find that Gen2 collections are too frequent, investigate why objects survive to Gen2. Often, it's due to event handlers or static references. Fix the root cause rather than forcing collections.
In summary, the GC is your ally, not your enemy. By understanding its behavior and avoiding these common pitfalls, you can write C# applications that are both memory-efficient and responsive. Start by profiling your current code, then apply the fixes that match your patterns. The result will be fewer pauses, lower memory usage, and happier users.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!