DEV Community

Cover image for Memory Management in C#
Rhuturaj Takle
Rhuturaj Takle

Posted on

Memory Management in C#

Memory Management in C

A deep-dive walkthrough of memory management in .NET — covering the stack vs. the managed heap, how the Garbage Collector actually works (mark-and-sweep, generations, the Large Object Heap), the IDisposable pattern and finalizers for unmanaged resources, using statements and declarations, weak references, common managed memory leaks (including the event-subscription leak this series' Events guide introduces), Span<T> and stackalloc for allocation-avoidance, and the practical, measured cases where manual GC intervention is actually warranted.


Table of Contents

  1. Introduction
  2. Stack vs. Heap: Where Objects Actually Live
  3. How the Garbage Collector Decides What's Alive
  4. Generations: Why the GC Doesn't Scan Everything Every Time
  5. The Large Object Heap
  6. Mark-and-Sweep-and-Compact: The Actual Collection Algorithm
  7. IDisposable and the Dispose Pattern
  8. using Statements and using Declarations
  9. Finalizers: The Safety Net, and Why They're Expensive
  10. The Full Dispose Pattern, Combining Both
  11. Weak References
  12. Common Managed Memory Leaks
  13. Span<T> and stackalloc: Avoiding Allocation Entirely
  14. GC Modes and When Manual Intervention Is (Rarely) Warranted
  15. Common Pitfalls
  16. Quick Reference Table
  17. Conclusion

Introduction

.NET manages memory for you — allocating objects, tracking which ones are still reachable, and reclaiming the ones that aren't, all without you writing explicit free() calls the way you would in C. This is a genuine, substantial convenience, but "automatic" doesn't mean "invisible" or "irrelevant to understand" — real applications still leak memory (not through forgotten free() calls, but through forgotten references, per Section 11), still pay real allocation costs worth minimizing in hot paths, and still need explicit cleanup for resources the garbage collector fundamentally cannot manage (file handles, network sockets, database connections) via IDisposable. This guide goes deep on what's actually happening underneath "the GC handles it" — generational collection, the Large Object Heap, the mark-and-sweep-and-compact algorithm, and the disposal patterns needed for anything the GC alone can't clean up.

Stack: fast, automatic, LIFO — value types and method-local state, cleaned up
  the instant a method returns, no GC involvement at all.
Managed Heap: where reference-type objects live — the GC tracks reachability
  and reclaims memory for objects nothing references anymore.
Unmanaged resources (file handles, sockets, DB connections): the GC does NOT
  know how to clean these up — this is what IDisposable exists for.
Enter fullscreen mode Exit fullscreen mode

1. Stack vs. Heap: Where Objects Actually Live

The stack: fast, automatic, scoped to a method call

void DoWork()
{
    int x = 42;           // value type — lives on the STACK
    Point p = new Point(1, 2); // if Point is a STRUCT (value type), this ALSO lives on the stack
} // when DoWork returns, the stack frame is popped — x and p's memory is reclaimed INSTANTLY, no GC involved
Enter fullscreen mode Exit fullscreen mode

The stack is a simple, extremely fast region of memory that grows and shrinks as methods are called and return — value types (per this series' OOP and Generics guides' discussion of the reference/value type divide) and method-local state generally live here, and cleanup is essentially free: when a method returns, its entire stack frame is popped in one step, with no tracking, no scanning, nothing for the garbage collector to do at all.

The managed heap: where reference-type objects live

void DoWork()
{
    var customer = new Customer(); // Customer is a CLASS (reference type) — the OBJECT lives on the HEAP
                                     // `customer` itself (the REFERENCE/pointer to it) lives on the stack
} // when DoWork returns, the REFERENCE `customer` is gone, but the OBJECT it pointed to
  //  is only reclaimed LATER, whenever the GC determines nothing reaches it anymore
Enter fullscreen mode Exit fullscreen mode

Every class instance (a reference type, per this series' OOP guide's Section 1) is allocated on the managed heap — the variable holding a reference to it might live on the stack (or inside another heap object), but the object itself persists on the heap until the garbage collector determines nothing in the program can reach it anymore, which is the entire subject of Sections 2 through 5.

Why this distinction matters for performance, not just correctness

Stack allocation: essentially free, no GC involvement, extremely fast.
Heap allocation: real cost — the GC needs to eventually track, scan, and
  potentially move this object; allocating heavily on the heap in a hot
  path is a genuine, measurable performance concern, which is exactly what
  this series' Generics guide's Section 8 identifies boxing as one specific
  cause of, and what Section 12 of this guide addresses directly.
Enter fullscreen mode Exit fullscreen mode

This connects directly to this series' Generics guide's boxing discussion — boxing a value type wraps it in a heap-allocated object specifically because value types normally avoid heap allocation and its associated GC cost entirely; understanding stack-vs-heap is the foundation that explains why that specific optimization (avoiding boxing) matters in the first place.


2. How the Garbage Collector Decides What's Alive

Reachability, not reference counting — the fundamental model .NET's GC uses

An object is considered "alive" (and thus NOT eligible for collection) if
  it is REACHABLE — if there's a chain of references leading to it,
  starting from a set of known "roots" (local variables currently on the
  stack, static fields, CPU registers). An object with NOTHING referencing
  it, directly or transitively, is GARBAGE, regardless of how it got that way.
Enter fullscreen mode Exit fullscreen mode

This is worth stating precisely because it's a genuinely different model from reference counting (used by some other languages/runtimes): .NET's GC doesn't track "how many things point to this object" incrementally as references are added or removed — it periodically walks outward from a set of roots, marking everything it can reach as alive, and treats everything else as reclaimable. This is what correctly handles circular references (two objects only referencing each other, but nothing else) as garbage, a case reference counting alone famously struggles with.

Roots: where the GC's reachability walk actually starts

Roots include: local variables on the current stack of every thread,
  static fields, and objects referenced from CPU registers at the moment
  of collection — anything genuinely still "in play" from the running
  program's perspective, right now.
Enter fullscreen mode Exit fullscreen mode

The GC's mark phase (Section 5) starts from these roots and follows every reference outward, transitively — an object referenced by a local variable, or by a field on another object that's itself reachable from a root, is alive; an object with no such chain leading to it is not, no matter how recently it was created or how much work went into constructing it.

This means you never explicitly free memory — you just stop referencing it

var customer = new Customer(); // allocated
customer = null; // no longer referenced by THIS variable — but might STILL be reachable elsewhere!
// the OBJECT becomes eligible for collection only once NOTHING reaches it, from ANY root
Enter fullscreen mode Exit fullscreen mode

Setting a variable to null doesn't "free" anything directly — it simply removes one path of reachability; the underlying object becomes eligible for collection specifically once no remaining path from any root leads to it. This is a genuinely important mental shift from manual memory management: your job is managing references, not memory directly — the GC handles the actual reclamation, once reachability genuinely drops to zero.


3. Generations: Why the GC Doesn't Scan Everything Every Time

The generational hypothesis: most objects die young

Empirically, across most real-world application workloads, the VAST
  MAJORITY of allocated objects become garbage very quickly — a temporary
  string built mid-computation, a short-lived request object, a LINQ
  query's intermediate results — while a smaller minority survive much
  longer (a cached configuration object, a long-lived service instance).
Enter fullscreen mode Exit fullscreen mode

This observed pattern — "most objects die young, a few live a long time" — is the foundation the entire generational garbage collection strategy is built on, and it's the reason .NET's GC doesn't treat every object identically or re-scan the entire heap on every single collection.

Gen 0, Gen 1, Gen 2: three generations, collected with decreasing frequency

Gen 0: newly allocated objects. Collected VERY frequently and VERY fast,
  since most Gen 0 objects are already garbage by the time a collection runs.
Gen 1: objects that SURVIVED at least one Gen 0 collection. A buffer
  between short-lived and genuinely long-lived objects.
Gen 2: objects that survived Gen 1 too — genuinely long-lived objects.
  Collected far less often, since scanning Gen 2 is comparatively expensive.
Enter fullscreen mode Exit fullscreen mode

Every new object starts in Gen 0. A Gen 0 collection is fast specifically because it only needs to examine Gen 0 objects (plus, per below, anything Gen 0 objects are referenced by) — objects that survive a Gen 0 collection are "promoted" to Gen 1, and objects surviving a Gen 1 collection are promoted to Gen 2. This tiered structure means the GC spends the vast majority of its effort on the small, fast, frequently-collected Gen 0, and only rarely pays the more expensive cost of scanning Gen 2.

The card table: how the GC avoids re-scanning all of Gen 2 on every Gen 0 collection

A Gen 0 collection needs to know whether any GEN 2 object references a
  GEN 0 object (which would keep that Gen 0 object alive) — without some
  mechanism to track this, every Gen 0 collection would need to scan ALL
  of Gen 2 too, defeating the entire point of generations. The GC
  maintains a lightweight "card table" tracking which small memory regions
  have had a write that MIGHT create such a cross-generational reference,
  so a Gen 0 collection only needs to re-check those specific flagged
  regions, not the entirety of Gen 2.
Enter fullscreen mode Exit fullscreen mode

This is a genuinely clever piece of the implementation worth knowing about, even at a high level — it's precisely what makes generational collection's core promise (fast, frequent Gen 0 collections) actually hold up in practice, rather than being undermined by the possibility of long-lived objects referencing short-lived ones.

Why this matters for how you write code: minimizing allocation, not "avoiding the GC"

You cannot, and should not try to, avoid the GC entirely — it's how memory
  gets reclaimed at all. What DOES matter, performance-wise, is minimizing
  UNNECESSARY allocation, especially in hot, frequently-executed paths —
  fewer Gen 0 allocations means less frequent Gen 0 collection pressure,
  and objects that don't NEED to survive to Gen 1/Gen 2 shouldn't be
  designed in a way that accidentally keeps them alive longer than necessary.
Enter fullscreen mode Exit fullscreen mode

This reframes the practical guidance correctly: the goal isn't "avoid triggering garbage collection" (an unavoidable, ordinary, and generally cheap part of a .NET application's operation) — it's minimizing needless allocation pressure, particularly of large numbers of small, short-lived objects in a genuinely hot code path, which is precisely the performance concern Section 12's Span<T>/stackalloc discussion addresses directly.


4. The Large Object Heap

Objects above a size threshold (85,000 bytes) are allocated differently

Small objects (< 85,000 bytes): allocated on the normal, generational
  heap described above (Sections 2-3).
Large objects (>= 85,000 bytes): allocated on a SEPARATE region, the
  Large Object Heap (LOH) — treated, functionally, as part of Generation 2,
  collected only during Gen 2 collections.
Enter fullscreen mode Exit fullscreen mode

The Large Object Heap exists because moving (compacting, per Section 5) a very large object during a collection is itself an expensive operation — copying a large byte array around in memory on every collection that happens to touch it would be wasteful, so large objects are handled separately, and historically were not compacted at all (though this has changed somewhat — see below).

Why LOH allocation is a genuine, specific performance concern

// ❌ Repeatedly allocating large arrays in a loop puts real, sustained pressure on the LOH
for (int i = 0; i < 1000; i++)
{
    byte[] buffer = new byte[100_000]; // each one is a LOH allocation
    ProcessBuffer(buffer);
}

// ✅ Reuse a single buffer instead, or use ArrayPool<T> (Section 12) to rent/return buffers
Enter fullscreen mode Exit fullscreen mode

Because the LOH is only collected during the comparatively infrequent, more expensive Gen 2 collections, and because (historically) it wasn't compacted at all, repeated large-object allocation is a well-known source of genuine memory fragmentation and GC pressure — a common, practical mitigation is reusing buffers (via ArrayPool<T>, per Section 12) rather than repeatedly allocating and discarding large arrays.

LOH compaction: available, but not automatic by default

GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; // opt-in
GC.Collect(); // the NEXT full collection will compact the LOH once
Enter fullscreen mode Exit fullscreen mode

Modern .NET does support LOH compaction, but it's not enabled by default for every collection (since compacting large objects is genuinely expensive) — this is a narrow, specific tool worth knowing exists for applications that have measured genuine LOH fragmentation as a real problem, rather than something to reach for preemptively.


5. Mark-and-Sweep-and-Compact: The Actual Collection Algorithm

Phase 1 — Mark: walk from the roots, flag everything reachable

Starting from every root (Section 2), the GC traverses the object graph,
  marking every object it can reach as "alive." Anything NOT marked by the
  end of this phase is, by definition, unreachable garbage.
Enter fullscreen mode Exit fullscreen mode

This is the concrete mechanical process underlying Section 2's reachability model — a real graph traversal, starting from roots and following references outward, marking each visited object.

Phase 2 — Sweep: reclaim the memory occupied by everything unmarked

Every object that was NOT marked as reachable in the Mark phase is
  garbage — its memory is now eligible to be reclaimed and made available
  for future allocations.
Enter fullscreen mode Exit fullscreen mode

This is the actual reclamation step — worth knowing that "sweep" here doesn't necessarily mean zeroing out or immediately overwriting memory; it means marking that memory as available for the next allocation to use.

Phase 3 — Compact: move surviving objects together, eliminating fragmentation

After sweeping, the SURVIVING objects can be scattered across memory with
  gaps between them (where the swept garbage used to be) — compaction
  moves the surviving objects together, into a single contiguous block,
  which both eliminates fragmentation AND lets future Gen 0 allocations
  happen via a simple, extremely fast "bump the pointer forward" operation
  rather than searching for a free slot of the right size.
Enter fullscreen mode Exit fullscreen mode

Compaction is what makes .NET's heap allocation for new objects so fast in the common case — because live objects are kept contiguous, allocating a new object is often just "take the next address after the last live object and advance a pointer," rather than the more complex free-list management a non-compacting allocator would need. This is a genuine, real advantage of managed, compacting garbage collection over manual memory management schemes that don't compact.


6. IDisposable and the Dispose Pattern

The problem: the GC only knows about MANAGED memory — not files, sockets, or database connections

The GC's entire model (Sections 2-5) is about tracking and reclaiming
  MANAGED memory — heap-allocated .NET objects. It has NO knowledge of,
  and no ability to directly manage, UNMANAGED resources: an open file
  handle, a network socket, a database connection, a native OS handle —
  these are resources the OPERATING SYSTEM tracks, entirely outside the
  GC's reachability model.
Enter fullscreen mode Exit fullscreen mode

This is the fundamental reason IDisposable exists at all — an object might hold a reference to an unmanaged resource (a file handle, say) internally, and even once that object becomes unreachable and is eventually collected, there's no guarantee the underlying OS-level file handle gets closed promptly, or even at all, without some explicit mechanism to release it.

The interface itself: a single method, Dispose()

public interface IDisposable
{
    void Dispose();
}

public class FileWriter : IDisposable
{
    private readonly FileStream _stream;

    public FileWriter(string path) => _stream = new FileStream(path, FileMode.Create);

    public void Write(string content) => _stream.Write(Encoding.UTF8.GetBytes(content));

    public void Dispose() => _stream.Dispose(); // release the UNDERLYING unmanaged resource, deterministically
}
Enter fullscreen mode Exit fullscreen mode

IDisposable establishes a simple, explicit contract: "call Dispose() when you're done with me, and I'll release whatever unmanaged resources I'm holding, right then, rather than waiting for the garbage collector to eventually notice I'm unreachable." This is the deterministic cleanup mechanism the GC's inherently non-deterministic collection timing (Sections 2-3 give no guarantee about when a given object will actually be collected) cannot provide on its own.

Why "eventually the GC will clean it up" isn't good enough here

// ❌ Leaks file handles until the GC eventually gets around to collecting these objects —
//    which might be a long time, and the OS has a LIMITED number of file handles available
for (int i = 0; i < 10000; i++)
{
    var writer = new FileWriter($"file{i}.txt"); // opens a real OS file handle
    writer.Write("data");
    // no Dispose() call — the FileStream stays open until GC eventually collects `writer`
}
Enter fullscreen mode Exit fullscreen mode

Operating systems impose real, finite limits on concurrently open file handles, sockets, and similar resources — relying on the GC's own timing (which prioritizes memory pressure, not resource scarcity, and per Section 3 might leave a Gen 2 object uncollected for a considerable time) to eventually release these is a genuine, practical way to exhaust those OS-level limits well before memory itself becomes a problem, which is exactly the failure mode IDisposable's deterministic Dispose() call exists to prevent.


7. using Statements and using Declarations

using statement: guarantees Dispose() is called, even if an exception occurs

using (var writer = new FileWriter("output.txt"))
{
    writer.Write("Hello");
} // Dispose() is called HERE, automatically, GUARANTEED — even if an exception was thrown inside the block
Enter fullscreen mode Exit fullscreen mode

This is, under the hood, equivalent to a try/finally block calling Dispose() in the finally — exactly the same guaranteed-execution pattern this series' OOP guide's discussion of lock (via Monitor.Enter/Exit) relies on, applied here to resource cleanup instead of mutual exclusion. The guarantee matters precisely because unmanaged resource leaks (Section 6) are a real, practical concern that shouldn't depend on the happy path always executing cleanly.

using declaration (C# 8+): the same guarantee, with less nesting

void ProcessFile()
{
    using var writer = new FileWriter("output.txt"); // no braces needed
    writer.Write("Hello");
    // Dispose() is called automatically at the END OF THE ENCLOSING SCOPE (here, the end of ProcessFile)
} // Dispose() actually happens HERE
Enter fullscreen mode Exit fullscreen mode

The using declaration (without the explicit block braces) is functionally identical to the using statement — Dispose() still happens deterministically and is still guaranteed even on exception — it just ties the disposal point to the end of the enclosing scope rather than an explicitly nested block, which is often more convenient and avoids the "pyramid of nested using blocks" that stacking several disposable resources with the older syntax could produce.

Multiple disposables, and why the nested syntax matters if you use the block form

using (var reader = new FileReader("input.txt"))
using (var writer = new FileWriter("output.txt")) // stacked using statements — both get disposed, INNERMOST first
{
    writer.Write(reader.ReadAll());
}
Enter fullscreen mode Exit fullscreen mode

Stacking using statements without braces between them (as above) is valid, idiomatic C# — each resource is still guaranteed to be disposed, in reverse order of acquisition (innermost/most-recently-acquired first), exactly mirroring how nested try/finally blocks would unwind.


8. Finalizers: The Safety Net, and Why They're Expensive

A finalizer runs if Dispose() was never called — a safety net, not a primary cleanup mechanism

public class FileWriter : IDisposable
{
    private FileStream _stream;

    public FileWriter(string path) => _stream = new FileStream(path, FileMode.Create);

    ~FileWriter() // the FINALIZER — syntax borrowed from C++ destructors, but behaves very differently
    {
        _stream?.Dispose(); // a SAFETY NET, in case Dispose() was never called
    }

    public void Dispose()
    {
        _stream?.Dispose();
        GC.SuppressFinalize(this); // tells the GC "the finalizer is no longer needed — I already cleaned up"
    }
}
Enter fullscreen mode Exit fullscreen mode

A finalizer (declared with ~ClassName()) is called by the garbage collector, if and only if the object still needs finalizing when it's collected — it exists specifically as a fallback for the case where a caller forgot to call Dispose(), ensuring the unmanaged resource eventually gets released regardless, even if later and less deterministically than Dispose() would have achieved.

Why finalizers are genuinely expensive, and why you should avoid relying on them

An object with an UN-SUPPRESSED finalizer is NOT collected in the normal,
  single-pass way — when the GC determines it's otherwise unreachable, it
  instead gets placed on a FINALIZATION QUEUE, and a dedicated finalizer
  thread runs its ~ClassName() method LATER. The object then typically
  needs to be PROMOTED to a later generation and collected AGAIN, on a
  SECOND pass, before its memory is actually reclaimed.
Enter fullscreen mode Exit fullscreen mode

This is the concrete, mechanical cost that makes finalizers a genuine performance concern to use sparingly: an object requiring finalization takes at least two garbage collection cycles to actually be reclaimed (one to run the finalizer, another to collect the now-finalized object), rather than the normal single pass — for an application creating many finalizable objects, this measurably increases GC overhead and can also prolong an object's effective lifetime (since it must survive until finalization runs), pushing more objects unnecessarily into Gen 1/Gen 2.

GC.SuppressFinalize(this): telling the GC the safety net isn't needed, because Dispose already ran

public void Dispose()
{
    _stream?.Dispose();
    GC.SuppressFinalize(this); // "I already cleaned up — skip the finalizer, collect me normally"
}
Enter fullscreen mode Exit fullscreen mode

This single call is what makes the combination of Dispose() and a finalizer efficient in the common case where Dispose() is called correctly — it removes the object from the finalization queue, letting it be collected in one normal pass, exactly as if it never had a finalizer at all; the finalizer only actually incurs its extra cost in the (ideally rare) case where Dispose() was genuinely never called.


9. The Full Dispose Pattern, Combining Both

The complete, standard pattern, as recommended by Microsoft's own guidance

public class ResourceHolder : IDisposable
{
    private bool _disposed = false;
    private FileStream? _managedResource; // a managed object that itself implements IDisposable
    private IntPtr _unmanagedHandle;       // a raw, unmanaged handle

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this); // Dispose() was called explicitly — the finalizer's safety net isn't needed
    }

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

        if (disposing)
        {
            _managedResource?.Dispose(); // only safe to touch OTHER MANAGED OBJECTS when called from Dispose()
        }

        ReleaseUnmanagedHandle(_unmanagedHandle); // ALWAYS safe — release the raw unmanaged resource either way

        _disposed = true;
    }

    ~ResourceHolder()
    {
        Dispose(false); // called by the GC — do NOT touch other managed objects here, they may already be finalized
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the complete, standard shape Microsoft's own documentation recommends, and it's worth understanding why it's structured this way: the bool disposing parameter distinguishes "this is being called from Dispose(), explicitly, by user code" (where it's safe to touch other managed objects, since they're guaranteed to still be valid) from "this is being called from the finalizer, by the GC" (where other managed objects might already have been finalized in an unpredictable order, making it unsafe to reference them — only the raw unmanaged resource, which this object owns directly and exclusively, is safe to release at that point).

When you genuinely need this full pattern, versus a simpler Dispose() alone

Only include a finalizer AT ALL if your class directly owns a raw,
  unmanaged resource (a raw handle obtained via P/Invoke, for instance) —
  if your class only holds OTHER IDisposable objects (like a FileStream,
  which already has its own finalizer), a finalizer on YOUR class is
  redundant; simply disposing the inner IDisposable in your own Dispose()
  is sufficient, since ITS finalizer already provides the safety net.
Enter fullscreen mode Exit fullscreen mode

This is a genuinely important scoping question worth getting right: the full pattern with a finalizer is specifically for classes that directly wrap a raw unmanaged handle — for the much more common case of a class that merely holds other IDisposable objects (which already have their own finalizers protecting them), a finalizer on the outer class adds Section 8's real overhead for no additional safety benefit, and should generally be omitted.


10. Weak References

The problem: sometimes you want to reference an object without keeping it alive

public class Cache
{
    private readonly Dictionary<string, object> _cache = new(); // a STRONG reference — keeps entries alive FOREVER,
                                                                    // even if nothing else in the app needs them anymore
}
Enter fullscreen mode Exit fullscreen mode

An ordinary reference (a "strong" reference, the default kind) keeps an object reachable, and therefore alive, for as long as the reference itself exists — this is exactly what you want most of the time, but it's a genuine problem for something like a memory-sensitive cache: caching an object strongly means it can never be collected, even under real memory pressure, even if the application would gladly recompute or re-fetch it rather than run low on memory.

WeakReference<T>: a reference that doesn't prevent collection

public class WeakCache
{
    private readonly Dictionary<string, WeakReference<CachedItem>> _cache = new();

    public void Add(string key, CachedItem item) => _cache[key] = new WeakReference<CachedItem>(item);

    public CachedItem? Get(string key)
    {
        if (_cache.TryGetValue(key, out var weakRef) && weakRef.TryGetTarget(out var item))
            return item; // still alive — return it
        return null; // was collected — caller needs to recompute/re-fetch it
    }
}
Enter fullscreen mode Exit fullscreen mode

A WeakReference<T> holds a reference to an object without counting as a root that keeps it reachable (Section 2) — the object can still be collected normally if nothing else references it strongly, and TryGetTarget is how you check whether it's still around (returning true and the object) or has since been collected (returning false). This is precisely the right tool for a cache that should yield to genuine memory pressure rather than holding every entry hostage indefinitely.

Worth knowing the trade-off: weak references add real complexity for a genuinely narrow benefit

Every use of a WeakReference<T> requires handling the "it might be gone"
  case explicitly, everywhere the cached value is used — this is real,
  ongoing complexity that only pays for itself when the underlying data
  genuinely benefits from being reclaimable under memory pressure (large,
  regeneratable, non-critical cached data) — for most ordinary caching
  needs, a size- or time-bounded cache (an explicit eviction policy, as
  covered in this series' Distributed Cache guide's Section 5) is a
  simpler, more predictable tool.
Enter fullscreen mode Exit fullscreen mode

Weak references are a genuinely specialized tool, not a default caching strategy — this series' Distributed Cache guide's Section 5 eviction-policy discussion covers the more commonly reached-for approach (explicit LRU/LFU/TTL-bounded caches) for most real-world caching needs; WeakReference<T> is worth reaching for specifically when you want the runtime itself, rather than an explicit policy, to decide when cached data should be reclaimed.


11. Common Managed Memory Leaks

"Leak" in a garbage-collected language means something different than in C, but it's genuinely real

There's no forgotten free() call here — a "memory leak" in C# means an
  object remains REACHABLE (per Section 2) — and therefore never collected
  — even though the application logically has no further use for it. The
  memory isn't LOST, it's just never RECLAIMED, because something,
  somewhere, is still (unintentionally) holding a reference to it.
Enter fullscreen mode Exit fullscreen mode

This reframing matters: a C# memory leak is always, structurally, an unintended reachability chain — some root, directly or transitively, still references an object the application actually considers "done with," and until that chain is broken, the GC (correctly, by its own rules) will never collect it.

The classic case: forgotten event subscriptions

Per this series' Events guide's Section 10, in full depth: subscribing to
  a long-lived publisher's event creates a reference FROM the publisher
  BACK TO the subscriber — if the subscriber's own intended lifetime ends
  but it never explicitly unsubscribes, the publisher's continued
  reachability (as a root, or reachable from one) keeps the "discarded"
  subscriber alive indefinitely, entirely invisibly.
Enter fullscreen mode Exit fullscreen mode

This is genuinely one of the most common real-world managed memory leaks in event-heavy .NET applications, and this series' Events guide covers the mechanics and the fix (explicit unsubscription, often via IDisposable) in full depth — worth cross-referencing directly here since it's a textbook example of Section 2's "reachability, not intent, determines what's alive" principle causing a real, practical leak.

Static fields and caches that only ever grow

public static class GlobalCache
{
    private static readonly Dictionary<string, object> _items = new(); // a STATIC field — a ROOT, per Section 2

    public static void Add(string key, object value) => _items[key] = value; // NEVER removed, EVER
}
Enter fullscreen mode Exit fullscreen mode

A static field is a root (Section 2) for the entire lifetime of the application domain — anything added to it, and never explicitly removed, stays reachable, and therefore alive, indefinitely, regardless of whether the application logically still needs it. An unbounded, ever-growing static cache (or, similarly, a static event with subscribers that are never removed) is a straightforward, common leak pattern, closely related to this series' Distributed Cache guide's Section 5 eviction-policy discussion — without some bound (size, time, or explicit removal), a cache is structurally a slow, steady leak.

Closures capturing more than intended

public Action CreateHandler()
{
    var largeObject = LoadVeryLargeDataStructure(); // large, but only genuinely needed briefly
    var relevantValue = largeObject.SmallRelevantField;

    return () => Console.WriteLine(relevantValue); // ❌ if this closure ACCIDENTALLY captures `largeObject`
                                                       //    instead of just `relevantValue`, the WHOLE large
                                                       //    object stays alive for as long as this delegate does
}
Enter fullscreen mode Exit fullscreen mode

Per this series' Delegates guide's Section 8 discussion of closures, a lambda captures variables, not just the specific values it references — if a closure inadvertently captures a reference to a much larger enclosing object (rather than just the specific small piece of data it actually needs), that entire larger object is kept alive for the closure's whole lifetime, which can be considerably longer and more surprising than the developer intended, especially if the resulting delegate is itself stored somewhere long-lived (an event subscription, a cached callback).


12. Span<T> and stackalloc: Avoiding Allocation Entirely

Span<T>: a view over contiguous memory, without necessarily allocating anything new

int[] array = { 1, 2, 3, 4, 5 };
Span<int> span = array.AsSpan(1, 3); // a VIEW over elements [2, 3, 4] — NO new array allocated, NO copy made

span[0] = 99; // mutating THROUGH the span mutates the ORIGINAL array directly
Console.WriteLine(array[1]); // 99
Enter fullscreen mode Exit fullscreen mode

Span<T> is a struct (a value type, per this series' Generics guide's reference/value distinction) that represents a view into an existing, contiguous block of memory — slicing, subdividing, or passing around a Span<T> involves no heap allocation at all, unlike the equivalent operation on an array (array[1..4] producing a genuinely new, separately-allocated array) — this is a real, meaningful allocation-avoidance tool specifically for hot paths doing a lot of array/string slicing and manipulation.

stackalloc: allocating a buffer on the stack instead of the heap

Span<int> buffer = stackalloc int[100]; // allocated on the STACK — zero heap allocation, zero GC involvement
for (int i = 0; i < buffer.Length; i++) buffer[i] = i * i;
Enter fullscreen mode Exit fullscreen mode

stackalloc, combined with Span<T> as the safe way to work with the resulting memory, lets you allocate a fixed-size buffer directly on the stack (Section 1) rather than the heap — this entirely sidesteps GC involvement (there's nothing for the garbage collector to ever track or collect here), at the cost of the stack's own limitations: the buffer's size needs to be known and reasonably small (stack space is much more limited than heap space, and a stackalloc that's too large risks a stack overflow), and it cannot outlive the method that created it.

Why these matter specifically for hot, allocation-sensitive paths

Per Section 3's guidance: the goal isn't avoiding the GC universally — it's
  minimizing UNNECESSARY allocation in code that runs often enough for
  allocation pressure to become a measurable cost. Span<T> and stackalloc
  are targeted tools for EXACTLY this situation — parsing, string
  manipulation, or numeric processing code that would otherwise allocate
  many small, short-lived arrays or substrings on every single call.
Enter fullscreen mode Exit fullscreen mode

This is the same "reach for it in a measured hot path, not by default everywhere" guidance this series has given for ValueTask<T> (in the Task guide) and Interlocked/lock-free patterns (in the Threading guide) — Span<T> and stackalloc are genuinely powerful, but they add real complexity (stack-only lifetime rules the compiler enforces strictly) that's only worth paying for once allocation has been identified as a genuine, measured bottleneck.


13. GC Modes and When Manual Intervention Is (Rarely) Warranted

Workstation vs. Server GC: two different tuning profiles for different application shapes

<!-- in the .csproj or runtimeconfig.json -->
<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
Enter fullscreen mode Exit fullscreen mode

Workstation GC (the default for most application types) is tuned for low latency on a single core or a small number of cores, minimizing pause times — appropriate for desktop and most client applications, where responsiveness matters more than raw allocation throughput. Server GC is tuned for high-throughput, multi-core server workloads — it uses a separate heap and collection thread per core, trading somewhat higher pause times for significantly better overall throughput under heavy, multi-threaded allocation load, and is the typical choice for ASP.NET Core web applications under real production traffic.

GC.Collect(): almost always the wrong tool, even though it exists

GC.Collect(); // ❌ in nearly all real application code, this makes things WORSE, not better
Enter fullscreen mode Exit fullscreen mode

Calling this manually forces an immediate, full collection — which sounds like it should help, but in practice usually hurts: it collects objects that would have died naturally on their own schedule anyway (wasting effort), and it can force premature promotion of genuinely short-lived Gen 0 objects that happened to survive just long enough to be caught mid-collection, pushing them into Gen 1/Gen 2 where they'll now live longer and cost more to eventually collect than if the GC had simply been left alone to run on its own, tuned schedule.

The narrow, genuine exceptions where manual intervention is defensible

// A genuinely defensible case: after a large, one-time operation known to have
// created a lot of now-dead large objects, immediately before a period where
// low, predictable latency matters more than normal (e.g., right before
// accepting new user connections after a bulk startup import)
LoadEntireDatasetAtStartup();
GC.Collect(); // deliberate, measured, and specifically justified — NOT a routine practice
Enter fullscreen mode Exit fullscreen mode

This is worth stating as a genuine, if narrow, exception rather than an absolute "never" — a deliberate, specifically justified, and measured (confirmed to actually help via profiling, not just assumed) call to GC.Collect() immediately after a known, large, one-time burst of garbage, before a latency-sensitive period begins, can be defensible. The default guidance remains firmly "don't call GC.Collect() in ordinary application code" — the GC's own generational, adaptive tuning (Sections 3-4) is very good at what it does, and manual intervention without measured justification is far more often counterproductive than helpful.


14. Common Pitfalls

Pitfall Why it hurts Better approach
Relying on the GC alone to release unmanaged resources (file handles, sockets, connections) The GC's timing is non-deterministic and driven by memory pressure, not OS resource scarcity — real limits can be exhausted long before collection happens Implement IDisposable for anything wrapping an unmanaged resource, and call Dispose() deterministically via using (Sections 6-7)
Adding a finalizer to a class that only holds other IDisposable objects Redundant overhead — the inner objects' own finalizers already provide the safety net; the outer finalizer adds a second, unnecessary collection pass Only add a finalizer to a class that directly owns a raw, unmanaged handle (Section 9)
Forgetting GC.SuppressFinalize(this) in Dispose() on a class with a finalizer The object still goes through the more expensive, two-pass finalization queue even when Dispose() was called correctly Always call GC.SuppressFinalize(this) as the last step of Dispose() when a finalizer is present (Section 8)
Never unsubscribing a short-lived object from a long-lived publisher's event A classic, common managed memory leak — the publisher's reference keeps the "discarded" subscriber reachable indefinitely Explicitly unsubscribe (often via IDisposable) when the subscriber's own lifetime ends (Section 11; this series' Events guide's Section 10)
An unbounded static cache or collection that only ever grows Static fields are permanent roots — anything added and never removed stays reachable, and therefore alive, for the application's entire lifetime Bound caches explicitly by size, time, or eviction policy; consider WeakReference<T> for genuinely memory-pressure-sensitive caching (Sections 10-11)
Calling GC.Collect() routinely, assuming it "helps" Usually counterproductive — it collects objects that would have died naturally anyway, and can force premature, costly promotion of short-lived objects Leave collection to the GC's own adaptive, generational tuning; reserve manual calls for specific, measured, justified exceptions (Section 13)
Repeatedly allocating large arrays/buffers in a loop Sustained pressure on the Large Object Heap, a well-known source of fragmentation, since LOH objects are only collected (and historically not compacted) during infrequent Gen 2 collections Reuse buffers, or use ArrayPool<T> to rent and return them rather than allocating fresh each time (Section 4)
A closure accidentally capturing an entire large object instead of just the small value it needs The whole large object stays alive for as long as the closure/delegate does, which can be considerably longer than intended Extract just the specific value needed into a local variable before the lambda, so the closure only captures that (Section 11)

Quick Reference Table

Concept C# Syntax Purpose
Stack allocation Value types, method-local state Fast, automatic, reclaimed instantly when a method returns — no GC involvement
Heap allocation new SomeClass() Where reference-type objects live, tracked for reachability by the GC
Generational collection Gen 0 → Gen 1 → Gen 2 Fast, frequent collection of short-lived objects; infrequent collection of long-lived ones
Large Object Heap Objects ≥ 85,000 bytes Handled separately, collected only during (less frequent) Gen 2 collections
Deterministic cleanup public void Dispose() { ... } Releases unmanaged resources immediately, not on the GC's own timing
Guaranteed disposal using var x = new Resource(); Ensures Dispose() runs even if an exception occurs
GC safety net ~ClassName() { ... } Runs if Dispose() was never called; expensive, should be paired with GC.SuppressFinalize
Non-owning reference WeakReference<T> References an object without preventing its collection under memory pressure
Zero-allocation view Span<T> A view over existing contiguous memory, avoiding a copy or new allocation
Stack-based buffer stackalloc int[100] Allocates on the stack, entirely bypassing the GC for that buffer

Conclusion

.NET's automatic memory management genuinely removes an entire class of bugs C developers deal with directly — but "automatic" describes reclamation, not the entire lifecycle: understanding generations is what explains why most garbage collection in a healthy application is fast and cheap, why the Large Object Heap and unbounded static caches are specific, known sources of real pressure, and why IDisposable exists at all — because the GC's reachability model, however well-tuned, has no concept of an OS file handle or a network socket, and deterministic cleanup for those resources has to be something you do explicitly, not something the runtime can infer on your behalf.

The recurring theme across this guide, consistent with this series' other C# deep dives, is that "automatic" doesn't mean "nothing to understand" — a memory leak in C# is a real, common, structural consequence of reachability (an event subscription, a static cache, a captured closure) rather than a forgotten free() call, and the fix requires understanding why an object is still reachable, not just that it shouldn't be. Knowing when the GC alone is sufficient, when IDisposable and deterministic cleanup are required instead, and when allocation itself — not just its eventual reclamation — is worth avoiding via Span<T> or stackalloc, is what turns "the GC handles it" from a comforting assumption into an accurate, working understanding of how .NET memory actually behaves.


Found this useful? Feel free to star the repo, open an issue with corrections, or share the file-handle-exhaustion-incident-that-had-nothing-to-do-with-memory-pressure story that made the IDisposable-versus-GC distinction click far better than any explanation ever could.

Top comments (0)