Advanced Track
Memory Management & Garbage Collector Internals
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.
What You'll Learn
- Tell value types from reference types, and stack from heap
- Explain how the generational GC (Gen 0 / 1 / 2) reclaims memory
- Know what the Large Object Heap is and why it matters
- Release resources on time with IDisposable and using blocks
- Choose IDisposable over finalizers for deterministic cleanup
- Spot and fix managed leaks from event handlers and static refs
💡 Real-World Analogy
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.
How .NET Manages Memory
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.
- 🧠 Stack vs heap — value types vs reference types (this lesson)
- ♻️ Generational GC — Gen 0, Gen 1, Gen 2, and the LOH
- 🧹 Deterministic cleanup —
IDisposableandusingbeat finalizers - 🚱 Avoiding leaks — detach events, bound your static collections
📊 GC Generations & the Large Object Heap
| Region | Contains | Collected | Cost |
|---|---|---|---|
| Gen 0 | Newly allocated objects | Very frequently | Cheap & fast |
| Gen 1 | Survived one Gen 0 collection | Less often | Moderate |
| Gen 2 | Long-lived objects | Rarely | Expensive (scans heap) |
| LOH | Objects ≥ 85,000 bytes | With Gen 2 | Expensive; not compacted by default |
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.
Worked example: value vs reference semantics
Run it and notice copying a class copies the reference, not the object.
using System;
class Program
{
// A class is a REFERENCE type — its objects live on the heap.
class Box { public int Value; }
static void Main()
{
// VALUE types (int, double, bool, struct) hold their data DIRECTLY.
// For a local like this, the value sits on the STACK — fast, automatic.
int a = 10;
int b = a; // b gets a COPY of a's value
b = 99; // changing b does NOT touch a
Console.WriteLine($"a = {a}, b = {b}"
...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.
Worked example: generations & promotion
Watch an object move Gen 0 -> Gen 1 -> Gen 2 as collections run.
using System;
class Program
{
static void Main()
{
// The GC sorts objects into 3 GENERATIONS by how long they survive.
// Gen 0 = brand new, Gen 1 = survived once, Gen 2 = long-lived.
var obj = new byte[1000]; // a fresh object starts in Gen 0
Console.WriteLine($"Just allocated: Gen {GC.GetGeneration(obj)}"); // Gen 0
// A Gen 0 collection promotes survivors to Gen 1.
GC.Collect(0);
Console.WriteLine($"After Gen 0 GC: Gen {G
...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.
🎯 Your turn: measure a big allocation
Fill in the blanks, then watch the tracked memory grow by ~4 MB.
using System;
class Program
{
static void Main()
{
// 🎯 YOUR TURN — measure how much memory a big array costs.
// GC.GetTotalMemory(false) returns the bytes the GC currently tracks.
// 1) Read the memory BEFORE allocating anything.
long before = GC.GetTotalMemory(false); // 👉 already done for you
Console.WriteLine($"Before: {before / 1024} KB");
// 2) Allocate an array of 1,000,000 ints (about 4 MB).
int[] big = new int[___];
...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.
Worked example: small heap vs the LOH
Compare a 1 KB array with a 100 KB array and the generation each reports.
using System;
class Program
{
static void Main()
{
// Objects of 85,000 bytes OR MORE go on the LARGE OBJECT HEAP (LOH).
// The LOH is collected only with Gen 2 and is NOT compacted by default,
// so it can fragment — leave gaps that waste memory.
// A small array stays on the normal heap, starting in Gen 0.
var small = new byte[1000];
Console.WriteLine($"Small array (1 KB): Gen {GC.GetGeneration(small)}"); // Gen 0
// A large
...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.
Worked example: IDisposable + using
Notice Dispose runs at the end of the using block, before the next line.
using System;
// IDisposable is the contract for DETERMINISTIC cleanup of resources the GC
// can't see (files, sockets, DB connections). You decide exactly WHEN it runs.
class FileLogger : IDisposable
{
public FileLogger(string path)
{
Console.WriteLine($"Opened log file: {path}"); // pretend we opened a file
}
public void Write(string message)
{
Console.WriteLine($"LOG: {message}");
}
// Dispose() is where you release the resource right away.
...Now you try. Make DbConnection implement IDisposable, finish its Dispose(), and wrap it in a using block. Fill in the three ___ blanks:
🎯 Your turn: implement IDisposable
Implement the interface, complete Dispose, and use a using block.
using System;
// 🎯 YOUR TURN — make this class clean up properly, then use it safely.
class DbConnection : ___ // 👉 implement the interface IDisposable
{
public DbConnection()
{
Console.WriteLine("Connection opened");
}
// 1) Implement Dispose() so it prints the close message.
public void Dispose()
{
Console.WriteLine(___); // 👉 print "Connection closed (Dispose ran)"
}
}
class Program
{
static void Main()
{
// 2) Wr
...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.
Worked example: finalizer (late) vs Dispose (on time)
See Dispose clean up immediately while the finalizer runs much later.
using System;
// A FINALIZER (~ClassName) runs when the GC collects the object — but you
// CANNOT control WHEN that happens, so it's the wrong tool for timely cleanup.
class Resource : IDisposable
{
private bool disposed = false;
public void Use() => Console.WriteLine("Using the resource");
// The dispose pattern: free things NOW, and skip the finalizer afterwards.
public void Dispose()
{
if (disposed) return;
Console.WriteLine("Dispose: cleaned up immedia
...🔎 Deep Dive: GC.Collect and GC.GetTotalMemory are for learning, not production
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.
// ✅ Fine in a lesson / quick experiment: long bytes = GC.GetTotalMemory(false); // approximate snapshot GC.Collect(); // force a collection to observe behaviour // ❌ In production, prefer to let the GC run itself, and measure with // proper tools (dotnet-counters, dotnet-trace, a profiler) instead.
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.
Worked example: event & static-collection leaks
See why these references keep objects alive, and how to release them.
using System;
// "Managed memory leak": an object stays reachable, so the GC never frees it.
// The two classic causes are undetached event handlers and static collections.
class Publisher
{
public event EventHandler? OnTick; // long-lived publisher
public void Tick() => OnTick?.Invoke(this, EventArgs.Empty);
}
class Subscriber
{
public Subscriber(Publisher p)
{
// The publisher now holds a reference to THIS subscriber via the handler.
// If we never detach, t
...Pro Tips
- 💡 Prefer
usingfor anything disposable: the C# 8+ using declarationusing var f = new FileLogger(...);disposes automatically at the end of the enclosing scope — no braces needed. - 💡 Keep objects short-lived: the GC's happy path is Gen 0. Allocating in tight loops and discarding quickly is fine; long-lived churn is what hurts.
- 💡 Don't call
GC.Collect()in production: let the runtime decide. Forcing collections almost always makes things slower, not faster. - 💡 Always unsubscribe from events you no longer need (
publisher.OnTick -= Handler;), or you'll keep subscribers alive — a top cause of leaks in long-running apps. - 💡 Bound your caches: a static
List/Dictionarythat only grows is a leak. Use a size limit, eviction policy, orWeakReferenceentries. - 💡 Implement the full dispose pattern when you hold unmanaged resources: a public
Dispose(),GC.SuppressFinalize(this), and a finalizer only as a safety net.
Common Errors (and the fix)
- Relying on a finalizer for timely cleanup: a finalizer runs whenever the GC gets around to it — possibly minutes later, possibly never. Put real cleanup in
Dispose()and call it viausing; keep the finalizer only as a backstop. - Forgetting to dispose: not disposing a file, socket, or DB connection leaks the underlying OS handle and can exhaust the pool ("too many open files"). Wrap every
IDisposablein ausing, or analyzers will warn "CA2000: Dispose objects before losing scope." - Memory leak from an undetached event handler:
publisher.OnTick += sub.Handler;without a matching-=keeps the subscriber alive as long as the publisher lives. Always unsubscribe, or use a weak event pattern. - Memory leak from a static collection: a
static Listthat you only everAddto grows forever — its items can never be collected. Remove items you're done with, or bound the collection's size. - Calling
GC.Collect()in production "to free memory": this forces a full, expensive collection at a bad time and usually hurts throughput. Remove it and fix the real cause — references you're still holding. - "System.ObjectDisposedException: Cannot access a disposed object": you used an object after its
usingblock already disposed it. Move the work inside theusing, or don't dispose until you're truly finished.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| Which generation? | GC.GetGeneration(obj) | 0, 1, or 2 |
| Tracked memory | GC.GetTotalMemory(false) | Approx bytes (learning) |
| Force a collection | GC.Collect() | Demos only, not production |
| Collections so far | GC.CollectionCount(2) | Per generation |
| Deterministic cleanup | using (var x = ...) { ... } | Calls Dispose() on exit |
| using declaration | using var x = ...; | Disposes at scope end |
| Skip finalizer | GC.SuppressFinalize(this) | After Dispose cleans up |
| LOH threshold | ≥ 85,000 bytes | Goes to Large Object Heap |
Frequently Asked Questions
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.
Q: Should I ever call GC.Collect()?
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.
Q: Why does the Large Object Heap matter?
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.
Mini-Challenge: A Disposable Buffer
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.
🎯 Mini-Challenge: a disposable buffer
Implement IDisposable and measure the memory freed after the using block.
using System;
// 🎯 MINI-CHALLENGE: A disposable buffer that reports its size
// 1. Make a class 'Buffer' that implements IDisposable.
// 2. In the constructor, take an int 'sizeKB' and allocate
// a byte[] of that many kilobytes (sizeKB * 1024).
// Print: "Allocated {sizeKB} KB".
// 3. In Dispose(), print "Buffer disposed".
// 4. In Main, read GC.GetTotalMemory(false) BEFORE and AFTER the using block,
// and print how much was freed once Dispose has run.
//
// ✅ Expected output (numbe
...🎉 Lesson Complete
- ✅ Value types hold data directly (stack); reference types hold an address to a heap object
- ✅ The GC uses generations — Gen 0 (cheap, frequent) to Gen 2 (expensive, rare)
- ✅ Objects ≥ 85 KB live on the Large Object Heap, collected with Gen 2 and not compacted
- ✅
IDisposable+usinggive deterministic, on-time cleanup of resources - ✅ Finalizers run unpredictably — a safety net, never your primary cleanup path
- ✅ Managed leaks come from undetached events and unbounded static refs — release them
- ✅
GC.Collect/GC.GetTotalMemoryare for learning and diagnostics, not production code - ✅ Next lesson: Unsafe Code &
Span<T>— zero-allocation, high-performance memory access
Sign up for free to track which lessons you've completed and get learning reminders.