The CLR’s garbage collector doesn’t actually "free" memory; it just stops tracking it, making it available for reuse.
Let’s watch this C# application eat memory until it throws an OutOfMemoryException.
using System;
using System.Collections.Generic;
using System.Threading;
public class MemoryHog
{
private static List<byte[]> _memoryList = new List<byte[]>();
public static void Main(string[] args)
{
Console.WriteLine("Starting memory allocation...");
try
{
while (true)
{
// Allocate a 1MB chunk of memory
byte[] data = new byte[1024 * 1024];
_memoryList.Add(data);
Console.WriteLine($"Allocated 1MB. Total: {_memoryList.Count}MB");
Thread.Sleep(10); // Small delay to observe
}
}
catch (OutOfMemoryException ex)
{
Console.WriteLine($"\nOutOfMemoryException caught: {ex.Message}");
Console.WriteLine($"Total memory allocated before OOM: {_memoryList.Count}MB");
}
Console.WriteLine("Program finished.");
}
}
If you run this code, you’ll see it steadily add 1MB chunks to a List<byte[]>. The List keeps references to these byte arrays. The CLR’s garbage collector (GC) sees these references and knows the memory is still "in use." It won’t reclaim that memory until the _memoryList itself is no longer reachable. If the _memoryList grows large enough to exhaust the available virtual address space or physical RAM, the CLR will eventually fail to allocate new memory for the byte[], and you’ll get that OutOfMemoryException.
The core problem is that memory is allocated but never released because the application holds onto references to it, preventing the GC from cleaning up. This isn’t a bug in the GC; it’s a consequence of the application’s design.
Common Causes of OutOfMemoryException
-
Growing Collections Without Bounds: This is exactly what our
MemoryHogexample demonstrates. AList<T>,Dictionary<TKey, TValue>, or any other collection that stores references to objects can grow indefinitely if not managed.- Diagnosis: Use a memory profiler (like JetBrains dotMemory, Visual Studio’s Diagnostic Tools) to inspect the heap. Look for large collections holding onto many objects. You can also use
GC.GetTotalMemory(false)periodically to track the unmanaged heap size. - Fix: Implement a strategy to limit collection size, like using a fixed-size collection, a bounded queue, or explicitly removing items when they are no longer needed. For our example, we’d add
_memoryList.Clear()or limit_memoryList.Capacityif we knew the target size. - Why it works: By removing references or capping the collection, you allow the GC to identify objects as unreachable and reclaim their memory.
- Diagnosis: Use a memory profiler (like JetBrains dotMemory, Visual Studio’s Diagnostic Tools) to inspect the heap. Look for large collections holding onto many objects. You can also use
-
Large Object Heap (LOH) Fragmentation: The GC treats objects larger than 85,000 bytes specially, allocating them on the Large Object Heap (LOH). The LOH is not compacted, meaning memory can become fragmented over time as large objects are allocated and then become unreachable. Even if you have enough total free memory, it might be broken into small, unusable chunks.
- Diagnosis: Memory profilers are crucial here. Look for a high number of large objects and significant fragmentation on the LOH. Tools like
perfviewcan show LOH allocation patterns and fragmentation. - Fix: Avoid allocating very large objects repeatedly if possible. If unavoidable, consider techniques like pooling large objects or breaking them into smaller chunks that can be managed on the regular heap, which is compacted. For specific LOH issues, consider
GC.Collect()withGCCollectionMode.Optimizedif you can tolerate the pause, or refactoring to useArrayPool<T>. - Why it works: Pooling reuses existing large buffers instead of allocating new ones, reducing LOH churn. Breaking down large objects allows the compacting GC to manage them more efficiently.
- Diagnosis: Memory profilers are crucial here. Look for a high number of large objects and significant fragmentation on the LOH. Tools like
-
Unmanaged Resource Leaks: C# manages managed memory (objects on the heap), but if your code directly interacts with unmanaged resources (like GDI handles, file handles, database connections) without properly releasing them using
Dispose(), these resources consume memory and system handles, eventually leading to anOutOfMemoryExceptionrelated to the operating system’s limits, not just the .NET heap.- Diagnosis: Use the Windows Performance Monitor (perfmon) to track handle counts (e.g., GDI Objects, User Objects) for your process. Check for steadily increasing handle counts. Tools like
Resource Monitorcan also show open handles. - Fix: Ensure all
IDisposableobjects are correctly disposed of, typically usingusingstatements ortry...finallyblocks to callDispose(). - Why it works: Calling
Dispose()on an object that wraps an unmanaged resource tells the underlying system to release that resource, freeing up the associated memory and handles.
- Diagnosis: Use the Windows Performance Monitor (perfmon) to track handle counts (e.g., GDI Objects, User Objects) for your process. Check for steadily increasing handle counts. Tools like
-
Excessive Thread Creation: Each thread consumes a certain amount of memory for its stack. Creating thousands of threads can exhaust process address space or physical memory.
- Diagnosis: Monitor the "Thread Count" performance counter for your process. If it’s in the thousands and climbing, this is a likely culprit.
- Fix: Use thread pooling (e.g.,
Task.RunorThreadPool.QueueUserWorkItem) instead of creating new threads manually. Design your application to manage concurrency with a limited number of threads. - Why it works: Thread pooling reuses a fixed set of threads, preventing the overhead of creating and destroying thousands of individual thread stacks.
-
String Interning Issues / Large String Operations: While strings are reference types, .NET often interns identical string literals to save memory. However, programmatically creating many unique, large strings can still consume significant memory. Also, certain string manipulation operations (like repeated concatenation in a loop without
StringBuilder) can create many intermediate string objects that aren’t immediately garbage collected.- Diagnosis: Use a memory profiler to examine string allocations. Look for a large number of unique strings or a high total memory footprint from strings.
- Fix: Use
StringBuilderfor building strings in loops. For repeated identical strings, consider usingstring.Intern()if appropriate, though this should be done judiciously. Analyze if all generated strings are truly necessary. - Why it works:
StringBuilderbuilds a mutable buffer, avoiding the creation of numerous intermediate string objects.string.Intern()ensures only one instance of a given string exists in memory.
-
Recursive Method Calls (Stack Overflow): While typically resulting in a
StackOverflowException, extremely deep recursion can, in rare scenarios or specific configurations, contribute to overall memory pressure if stack frames themselves are large or if the process is already memory-constrained, leading to anOutOfMemoryExceptionwhen trying to allocate the next stack frame.- Diagnosis: Check for deeply recursive methods in your code. Monitor stack usage if possible, though direct stack memory usage is harder to track than heap.
- Fix: Rewrite recursive methods iteratively using loops and explicit data structures (like a
Stack<T>). - Why it works: Iterative solutions replace the call stack overhead with heap allocations (e.g., for the explicit stack), which are managed by the GC and generally less prone to abrupt exhaustion than fixed-size thread stacks.
After fixing the primary cause of an OutOfMemoryException, the next error you’re likely to encounter is a GC.CollectionHeapTooSmallException if the process still can’t allocate sufficient contiguous memory for its needs, or potentially a BadImageFormatException if corrupted metadata leads to unexpected memory layouts.