Memory Management

By the end of this lesson you'll understand where your data actually lives — stack vs heap — how .NET's garbage collector reclaims it across generations, and how to clean up resources on time with IDisposable and using instead of leaving leaks behind.

Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.

Part of the free C# course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

Think of the garbage collector as an automatic tidy-up crew for a busy office. Your desk is Gen 0 — the crew sweeps it constantly, because most things on a desk (a coffee cup, a sticky note) are short-lived. Items that have hung around get filed into the Gen 1 cabinet, and anything that survives even longer moves to the Gen 2 archive room, which is cleaned out rarely because reorganising it is expensive. Oversized items — a filing cabinet itself — go straight to the warehouse (the Large Object Heap) , which the crew almost never rearranges. The crew is brilliant, but it only collects what nobody is still holding on to. If you keep a string tied to an old box (an undetached event handler, a static list), the crew leaves it exactly where it is — that's a memory leak. And for things the crew can't see (a running tap, an open door — files, sockets), you have to close them yourself with IDisposable .

In C# you almost never call free or delete . The runtime tracks every object you allocate, and the garbage collector (GC) automatically reclaims any object that your code can no longer reach. "Reachable" means there's still a path of references to it from a live variable, field, or static — once that path is gone, the object is eligible for collection.

The GC is fast precisely because of a key observation: most objects die young . So instead of scanning the whole heap every time, it sorts objects into generations and collects the youngest ones far more often. This lesson walks through where objects live, how generations work, and where automatic management stops — the resources you must release yourself.

The big takeaway: keep most objects small and short-lived so they die in cheap Gen 0 collections. Long-lived and large objects push work into expensive Gen 2 / LOH collections.

1. Stack vs Heap, Value vs Reference

Every variable is one of two kinds. A value type ( int , double , bool , a struct ) stores its data directly ; for a local variable that lives on the stack — a fast, self-cleaning region that unwinds automatically when a method returns. A reference type ( class , array, string ) stores a reference — an address — pointing at the real object, which lives on the heap and is what the GC manages. The practical consequence: copying a value type copies the data, but copying a reference type copies only the arrow, so two variables can end up changing one shared object. Read this worked example and run it.

2. How the Generational GC Works

The GC reclaims unreachable objects in three phases — mark (find everything still reachable), sweep (free the rest), and compact (shuffle survivors together to remove gaps). To avoid scanning the whole heap each time, it groups objects into generations : new objects start in Gen 0 , and anything that survives a collection is promoted to Gen 1 , then Gen 2 . Because Gen 0 is small and collected constantly, short-lived objects are cleaned up almost for free. This example forces collections so you can watch an object get promoted.

Your turn. GC.GetTotalMemory(false) reports how many bytes the GC is currently tracking, so reading it before and after an allocation shows the cost. Fill in the two ___ blanks and run it.

3. The Large Object Heap (LOH)

Any object of 85,000 bytes or more — typically a big array or buffer — is allocated on the Large Object Heap rather than the normal (small object) heap. The LOH is collected only during a Gen 2 collection and, by default, is not compacted , so allocating and freeing big arrays repeatedly leaves gaps that fragment memory. The lesson here is to reuse large buffers instead of churning new ones. Run this to see a large array report as Gen 2.

4. Deterministic Cleanup: IDisposable & using

The GC handles memory , but it knows nothing about unmanaged resources — open files, network sockets, database connections, OS handles. Those must be released promptly, and you can't wait for an unpredictable GC. The contract for this is IDisposable : implement a Dispose() method that frees the resource, then wrap usage in a using block so Dispose() is called automatically at the closing brace — even if an exception is thrown. Read this worked example, then you'll write one.

Now you try. Make DbConnection implement IDisposable , finish its Dispose() , and wrap it in a using block. Fill in the three ___ blanks:

5. Finalizers vs IDisposable

A finalizer (written ~ClassName() ) is a method the GC runs when it collects the object. The problem: you have no control over when that is — it could be seconds or minutes later, or not before your program exits. So a finalizer is the wrong tool for timely cleanup ; it's only a safety net for when someone forgets to dispose. Best practice is the dispose pattern: do the real cleanup in Dispose() , and call GC.SuppressFinalize(this) so the GC skips the now-unnecessary finalizer. This example shows both paths side by side.

You've seen GC.Collect() in the examples above. It's there only to demonstrate generations and finalizers on demand. In real code you should almost never call it: the GC is heavily tuned to decide the best moment to run, and a manual GC.Collect() forces an expensive full collection at the wrong time, often hurting performance and pausing every thread.

GC.GetTotalMemory(forceFullCollection) is similar — handy in a tutorial to see allocation cost, but it's an approximation, not a precise accounting tool. Pass false for a quick snapshot; passing true forces a collection first.

Rule of thumb: if you're reaching for GC.Collect() to "fix" a memory problem, the real fix is almost always to stop holding references you no longer need.

6. Avoiding Managed Memory Leaks

A garbage-collected language can still leak memory — not because the GC fails, but because you keep objects reachable so it's not allowed to free them. The two classic culprits: undetached event handlers (subscribing to an event makes the publisher hold a reference to the subscriber, so the subscriber can't be collected until you unsubscribe), and static collections (anything you add to a static list lives for the whole program unless you remove it). Run this to see both leaks — and their fixes.

Q: If C# has a garbage collector, why do I need IDisposable at all?

The GC only reclaims managed memory . It doesn't know about files, sockets, or database connections — those are unmanaged resources the OS hands out. IDisposable lets you release them the moment you're done, instead of waiting for an unpredictable GC.

Almost never in production. It forces an expensive full collection at a time the GC didn't choose, usually hurting performance. It's fine in a tutorial or micro-benchmark to observe behaviour, which is the only reason it appears in these examples.

Q: Can a garbage-collected program leak memory?

Yes — a "managed leak." If something stays reachable (an event subscription you never detach, a static list you only add to), the GC isn't allowed to free it. The fix is to stop holding the reference: unsubscribe, clear, or bound the collection.

Q: What's the difference between a finalizer and Dispose() ?

Dispose() is deterministic — you (or a using block) call it at a known moment. A finalizer runs whenever the GC collects the object , which you can't predict. Use Dispose() for cleanup; keep a finalizer only as a safety net.

Objects ≥ 85,000 bytes live on the LOH, which is collected only with expensive Gen 2 passes and isn't compacted by default, so churning big arrays fragments memory. Reuse large buffers rather than allocating fresh ones.

No blanks this time — just a brief and an outline. Build a Buffer class that implements IDisposable , allocates an array in its constructor, and prints when it's disposed. In Main , measure memory before and after a using block to show that disposing frees the buffer. Run it and check your output against the comments.

Practice quiz

Where does a value type (like an int local) typically store its data?

  • On the heap
  • In a static field
  • On the stack, holding the data directly
  • On the Large Object Heap

Answer: On the stack, holding the data directly. A value type stores its data directly; for a local variable that lives on the fast, self-cleaning stack.

When you copy a reference type variable, what gets copied?

  • Only the reference (address), so both variables point at the same object
  • The whole object is deep-copied
  • Nothing — it throws
  • A frozen snapshot of the object

Answer: Only the reference (address), so both variables point at the same object. Copying a reference type copies only the arrow to one shared object, so changing it through one variable is visible through the other.

In which generation does a brand-new object start?

  • Gen 1
  • Gen 2
  • The Large Object Heap
  • Gen 0

Answer: Gen 0. New objects start in Gen 0, which is collected often and cheaply. Survivors are promoted to Gen 1, then Gen 2.

Which generation is the most expensive to collect?

  • Gen 0
  • Gen 2
  • Gen 1
  • All are equal cost

Answer: Gen 2. Gen 0 is cheap and frequent; Gen 2 (long-lived objects) is collected rarely and expensively because it scans the heap.

At what size does an object go on the Large Object Heap?

  • 85,000 bytes or more
  • 8,500 bytes or more
  • 850,000 bytes or more
  • Any array

Answer: 85,000 bytes or more. Objects of 85,000 bytes or more go on the LOH, which is collected only with Gen 2 and is not compacted by default, so it can fragment.

What is IDisposable for?

  • Reclaiming managed memory automatically
  • Making objects immutable
  • Deterministic cleanup of resources the GC can't see (files, sockets, connections)
  • Forcing a Gen 2 collection

Answer: Deterministic cleanup of resources the GC can't see (files, sockets, connections). The GC handles managed memory but not unmanaged resources. IDisposable's Dispose() lets you release those promptly, on time.

When does a using block call Dispose()?

  • When the GC next runs
  • At the closing brace of the block, even if an exception is thrown
  • Only if you call it manually
  • When the program exits

Answer: At the closing brace of the block, even if an exception is thrown. A using block calls Dispose() automatically at the closing brace, including when an exception is thrown inside it.

Why is a finalizer (~ClassName) the wrong tool for timely cleanup?

  • It runs too early
  • It is slower than Dispose by a fixed amount
  • It cannot access fields
  • You can't control when it runs — it fires whenever the GC collects the object

Answer: You can't control when it runs — it fires whenever the GC collects the object. A finalizer runs whenever the GC collects the object, which is unpredictable. It's only a safety net; Dispose() gives deterministic cleanup.

What does GC.SuppressFinalize(this) do, called inside Dispose()?

  • Forces an immediate collection
  • Tells the GC to skip the now-unnecessary finalizer since cleanup already ran
  • Reopens the resource
  • Promotes the object to Gen 2

Answer: Tells the GC to skip the now-unnecessary finalizer since cleanup already ran. After Dispose() cleans up, GC.SuppressFinalize(this) tells the GC the finalizer is no longer needed, avoiding extra finalization work.

What is a common cause of a managed memory leak?

  • Using a using block
  • Disposing objects too early
  • An undetached event handler or an unbounded static collection keeping objects reachable
  • Allocating short-lived objects

Answer: An undetached event handler or an unbounded static collection keeping objects reachable. If something stays reachable — an event subscription you never detach, or a static list you only add to — the GC isn't allowed to free it.