Every C# developer knows the garbage collector exists, but few anticipate how their everyday coding patterns can silently sabotage performance. A single forgotten event handler, an accidental closure over a large object, or an overlooked boxing conversion can cause memory pressure that ripples through the entire application. This guide focuses on the specific gotchas that trip up experienced teams, offering concrete fixes you can apply today.
Why Memory Management Matters More Than Ever
Modern C# applications run on servers handling thousands of requests per second, on mobile devices with limited RAM, and in cloud environments where every megabyte costs money. The garbage collector (GC) is remarkably good at its job, but it works best when you understand its assumptions. Violate those assumptions—by creating too many short-lived objects, by holding references longer than needed, or by triggering frequent large-object heap collections—and performance can tank.
Consider a typical web API endpoint that processes a JSON payload. If the handler allocates temporary collections for each request without reusing buffers, the GC may run dozens of times per second. Each collection pauses all threads briefly, but those pauses add up. Under load, the server spends more time collecting garbage than doing actual work. This is not a theoretical problem; many production incidents trace back to allocation patterns that seemed harmless in isolation.
The stakes are higher now because applications are more complex. Dependency injection containers, async/await state machines, and LINQ expressions all generate hidden allocations. A developer who does not know where allocations occur cannot optimize effectively. This guide aims to demystify those hidden costs and give you a mental model for spotting them.
Who Should Read This
This article is for C# developers who have encountered unexpected pauses, high memory usage, or OutOfMemoryExceptions in production. It assumes you know the basics of classes, structs, and the GC, but want to go deeper. If you have ever wondered why a simple change from List to Array improved performance, or why finalizers seem to cause delays, you will find answers here.
Core Idea: The GC Is Your Friend, But It Has Rules
The .NET garbage collector is a generational, compacting collector. It divides the heap into three generations: Gen 0 for short-lived objects, Gen 1 for survivors, and Gen 2 for long-lived objects. Most collections happen in Gen 0 and are fast. The problem arises when objects accidentally survive into Gen 1 or Gen 2, or when large objects (≥85 KB) end up on the Large Object Heap (LOH), which is not compacted and can cause fragmentation.
The golden rule is simple: keep objects alive only as long as necessary, and prefer small, short-lived allocations over large, persistent ones. But applying this rule in practice requires understanding where hidden references lurk. For example, an event handler subscribed to a static event will keep the subscriber alive forever, even if the subscriber goes out of scope. Similarly, capturing a local variable in a lambda extends its lifetime to match the delegate.
Another key insight is that value types (structs) are not always allocated on the stack. When a struct is boxed—converted to object—it moves to the heap. Boxing happens implicitly in many scenarios: passing a struct to a method expecting object, using it as a generic type argument with a non-value-constrained parameter, or storing it in a non-generic collection like ArrayList. Each boxing allocation creates garbage that must be collected.
The Cost of Ignorance
Teams that ignore these rules often face a death-by-a-thousand-cuts scenario. No single allocation is problematic, but the aggregate pressure forces frequent GCs. Profiling reveals that the application spends 20% of CPU time in GC. Fixing the hotspots—reducing allocations, reusing buffers, avoiding boxing—can cut that to under 5% with minimal code changes.
How the Garbage Collector Works Under the Hood
To fix memory traps, you need to understand the GC's mechanics. The GC uses a mark-and-compact algorithm. During a collection, it traverses the object graph starting from roots (static fields, thread stacks, CPU registers) and marks all reachable objects. Unreachable objects are considered garbage. Then it compacts the remaining objects to reduce fragmentation.
The generational hypothesis states that most objects die young. By focusing collection efforts on Gen 0, the GC minimizes pause times. A Gen 0 collection typically takes less than a millisecond. However, if an object survives a Gen 0 collection, it gets promoted to Gen 1. Survive another, and it goes to Gen 2. Gen 2 collections are full heap sweeps and can take tens of milliseconds or more—especially if the LOH is involved.
The LOH is a separate heap for objects ≥85 KB. Because compacting large objects is expensive, the GC does not compact the LOH by default (though you can force it in .NET 5+). This means that over time, the LOH can become fragmented, leading to OutOfMemoryException even when total free space is sufficient.
Roots and References
An object is considered alive if it is reachable from a root. Common roots include static variables, local variables on active threads, and GC handles (e.g., from P/Invoke). A common gotcha is that a local variable in a method is a root only while the method is executing. But if the variable is captured by a lambda, the compiler generates a closure object that keeps the variable alive as long as the delegate is referenced.
Static events are another classic trap. If a short-lived object subscribes to a static event, the event's delegate holds a reference to the subscriber, preventing its collection. This is a frequent cause of memory leaks in desktop and ASP.NET applications.
Walkthrough: Fixing a Hidden Allocation Hotspot
Let us walk through a realistic scenario. Imagine you have a service that processes incoming sensor readings. Each reading is a struct with a timestamp and a float value. The service needs to compute a running average of the last 100 readings.
public struct SensorReading
{
public DateTime Timestamp;
public float Value;
}
public class SensorProcessor
{
private Queue<SensorReading> _readings = new Queue<SensorReading>();
public void AddReading(SensorReading reading)
{
_readings.Enqueue(reading);
if (_readings.Count > 100)
_readings.Dequeue();
}
public float GetAverage()
{
float sum = 0;
foreach (var r in _readings)
sum += r.Value;
return sum / _readings.Count;
}
}At first glance, this looks fine. But profiling reveals high GC allocations. Why? The Queue<SensorReading> is a generic collection, so no boxing occurs. The issue is that foreach on a Queue<T> allocates an enumerator object because Queue<T> does not have a public struct enumerator (unlike List<T>). Each call to GetAverage allocates a heap object for the enumerator.
The fix is to use a List<SensorReading> with a circular buffer approach, or to manually iterate using an index. A better design is to pre-allocate an array of size 100 and maintain a head/tail pointer. This eliminates all per-call allocations.
public class SensorProcessor
{
private readonly SensorReading[] _buffer = new SensorReading[100];
private int _head = 0;
private int _count = 0;
public void AddReading(SensorReading reading)
{
_buffer[_head] = reading;
_head = (_head + 1) % 100;
if (_count < 100) _count++;
}
public float GetAverage()
{
float sum = 0;
for (int i = 0; i < _count; i++)
sum += _buffer[i].Value;
return sum / _count;
}
}This version allocates zero memory per call after initialization. The GC pressure disappears, and the method runs faster because there is no enumerator overhead.
Lessons from the Walkthrough
The example illustrates a common pattern: hidden allocations in seemingly innocent code. The enumerator allocation was invisible until profiled. The fix required understanding how foreach works and choosing a data structure that avoids heap allocations. Always profile before optimizing, but be aware that .NET's BCL has many such hidden allocations.
Edge Cases and Exceptions
Not all memory management rules are absolute. There are cases where the standard advice does not apply or where the trade-offs shift.
When Structs Are Worse Than Classes
It is tempting to replace all small classes with structs to avoid heap allocations. But structs have their own costs: they are copied by value, so large structs (more than a few fields) cause expensive copying. Also, structs that are passed to methods expecting interfaces or object get boxed. If you have a struct that implements an interface and you call a method via that interface, boxing occurs. In such cases, a class might be more efficient despite the heap allocation.
Finalizers and the Finalization Queue
Objects with finalizers (destructors) are handled specially. When the GC discovers a finalizable object is unreachable, it does not reclaim it immediately. Instead, it places the object on the finalization queue. A dedicated thread runs the finalizer later. This delays memory reclamation and can cause objects to survive into higher generations. The rule is: avoid finalizers unless you are holding unmanaged resources. Use SafeHandle or IDisposable instead.
The Large Object Heap Trap
Allocations ≥85 KB go to the LOH. Because the LOH is not compacted, repeated allocations of similar size can lead to fragmentation. For example, allocating and releasing large byte arrays in a loop can fragment the LOH until no contiguous block is available, even though total free space is enough. Mitigations include pooling large buffers (e.g., ArrayPool<T>) or using GCSettings.LargeObjectHeapCompactionMode in .NET 5+.
Limits of the Approach
No memory management strategy is a silver bullet. Even with perfect code, the GC imposes some overhead. Understanding the limits helps you set realistic expectations.
When Optimization Is Not Worth It
If your application is not CPU-bound or memory-constrained, spending days micro-optimizing allocations may yield negligible user-visible improvements. Always measure first. Use a profiler to identify the top 3 allocation hotspots. Fix those, and stop. The law of diminishing returns applies: the last 10% of optimization often takes 90% of the effort.
Trade-offs with Object Pooling
Object pooling reduces GC pressure by reusing objects, but it introduces complexity: you must ensure objects are properly reset before reuse, and pooling can increase memory usage if the pool is too large. For short-lived objects that are cheap to allocate, pooling may actually be slower due to synchronization overhead. Use pooling judiciously, typically for objects that are expensive to create (e.g., database connections) or that cause high GC frequency (e.g., large buffers).
The Cost of Immutability
Immutable objects are safe and easy to reason about, but they generate more garbage because every modification creates a new instance. In performance-critical paths, mutable structs or pooled objects may be a better choice. Balance correctness with performance based on the specific context.
Reader FAQ
Q: Does using Span<T> always avoid allocations?
A: Span<T> is a ref struct and lives on the stack, so it does not allocate heap memory itself. However, operations that create a span over heap-allocated arrays do not allocate, but if you convert a span to a Memory<T> or use it in an async method, boxing can occur. Use spans for synchronous, stack-only scenarios.
Q: Should I avoid LINQ entirely for performance?
A: LINQ adds overhead due to delegate allocations and iterator state machines. For hot paths, manual loops are often faster. But for most application code, the readability gain outweighs the performance cost. Profile to know if LINQ is your bottleneck.
Q: How do I detect memory leaks in production?
A: Use a memory profiler (dotMemory, PerfView) or capture dumps and analyze with WinDbg. Look for unexpected growth in Gen 2 or LOH. Common culprits include static events, cached data without eviction, and thread-local storage.
Q: Does async/await allocate?
A: Yes, each async method that awaits a non-completed task allocates a state machine object on the heap. For high-throughput scenarios, consider using ValueTask to reduce allocations, or pool the state machine (advanced).
Q: What is the best way to reduce GC pauses?
A: Reduce allocations overall, especially in Gen 0. Use ArrayPool, StringBuilder, and avoid LINQ in hot paths. For server GC, consider using the workstation GC with concurrent mode if pause times are critical.
Practical Takeaways
Memory management in C# is not magic, but it requires awareness. Here are the key actions you can take starting today:
- Profile first. Use a memory profiler to find your top allocation sites. Do not guess.
- Eliminate hidden allocations. Replace
foreachon collections without struct enumerators, avoid boxing by using generic collections, and watch out for closures. - Pool expensive resources. Use
ArrayPool<T>for large arrays andObjectPool<T>for objects that are costly to create. - Prevent leaks. Unsubscribe from static events, clear event handlers, and use weak references for caches when appropriate.
- Measure impact. After each optimization, measure again. Sometimes the fix does not move the needle, and that is okay.
By internalizing these patterns, you will write C# code that respects the garbage collector's strengths and avoids its pitfalls. Your applications will run faster, use less memory, and behave more predictably under load. Start with one hotspot today—your users will thank you.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!