.NET Garbage Collection Explained: How the GC Works
You've never called free() in C#. Something else has been doing that job, silently, this entire time.
Every new Customer(), every new List<int>(), every object you've ever created across two entire books of lessons has gone somewhere — and, eventually, has been cleaned up. You never wrote code to release that memory. You never even had to think about it. That's not because memory management is unnecessary in .NET; it's because someone else is doing it for you, continuously, in the background, for the entire life of your program.
That someone is the Garbage Collector (GC) — arguably the single most consequential CLR service you've been relying on, and the one this whole tier has been building toward. It's time to actually understand what it does and how.
In this lesson, you'll learn why automatic memory management exists, what "garbage" actually means to the runtime, and how the GC decides what's safe to reclaim and when.
What Is It?
The Simple Explanation
The Garbage Collector is a part of the CLR that periodically looks at everything currently on the managed heap, figures out which objects your program can no longer possibly reach, and frees the memory those unreachable objects were using — automatically, without you writing any cleanup code.
The Technical Definition
The GC is a tracing, reachability-based automatic memory manager. It does not track "how many things point at this object" (that's a different technique called reference counting, used by some other languages). Instead, periodically, it starts from a known set of roots — local variables currently on the stack, static fields, CPU registers holding object references — and walks the graph of everything reachable from those roots. Anything not reachable this way is, by definition, garbage: your program has no possible way to ever use it again, so it's safe to reclaim.
Manual Memory Management (C/C++ style)
- You call
malloc/free(ornew/delete) explicitly - Memory leak — forget to free, memory is never reclaimed
- Use-after-free — access memory after it's been freed; undefined behavior, often exploitable
- Double-free — free the same memory twice, corrupting the allocator's internal state
Automatic (Managed) — .NET's GC
- You never explicitly free managed memory
- The GC reclaims memory only when it's provably unreachable — no use-after-free, no double-free by construction
- Trade-off: the GC needs to pause or interleave work with your program to do its job, which is a real, non-zero cost
Why Does It Exist?
The Problem — manual memory management is genuinely dangerous
In languages without a GC, the programmer is personally responsible for deciding exactly when a piece of allocated memory is no longer needed and calling a function to release it. This sounds simple in principle, but at real-world scale it's one of the largest sources of serious bugs in software history:
- Memory leaks — an object is allocated, becomes unreachable, but nothing ever calls
free()on it. The memory is gone for the life of the process. A long-running server leaking memory on every request will eventually run out of memory and crash. - Use-after-free — code frees memory, but another piece of code (or the same code, later) still holds a pointer to it and uses it anyway. The memory might now belong to something else entirely, leading to silent data corruption or a crash — and historically, a huge source of security vulnerabilities.
- Double-free — the same block of memory gets freed twice. This corrupts the allocator's internal bookkeeping and can cause crashes or exploitable memory corruption far away from the actual bug.
These bugs are notoriously hard to find because the symptom (a crash, or corrupted data) often shows up long after and far away from the actual mistake (the missing or extra free() call).
The Solution
The GC removes the decision of "when to free this" from the programmer entirely. You simply stop using an object — by letting the last reference to it go out of scope, or setting it to null, or letting a containing object become unreachable — and the GC figures out, safely and automatically, when it's truly gone for good. Because it only ever reclaims objects that are provably unreachable from any root, use-after-free and double-free bugs on managed memory are structurally eliminated, not just made less likely. Memory leaks are still possible in .NET (if something keeps an unwanted reference alive), but the entire class of "freed too early" or "freed twice" bugs simply cannot happen through ordinary managed code.
Big Picture
- Local variables on the stack · Static fields · CPU registers currently holding a reference · Certain GC handles used by interop
- Follow every reference field, every array element, transitively, marking each object found as "reachable"
- If an object was never reached by the trace, your program has no possible path left to access it — ever
- Reachable objects survive; unreachable objects' memory is returned to the heap for future allocations
How It Works
Step 1 — Objects are allocated, and become reachable via a root
void ProcessOrder()
{
var order = new Order(); // 'order' (a local var) is a root — Order object is reachable
order.Items.Add(new OrderItem("Widget", 3)); // reachable via order.Items
}
While ProcessOrder is executing, order is a local variable on the stack — a GC root. The Order object it points to is reachable, and so is the OrderItem reachable through order.Items.
Step 2 — The method returns; the root disappears
Once ProcessOrder returns, the local variable order ceases to exist — it's no longer a root. If nothing else in the program still holds a reference to that Order object (say, it wasn't saved anywhere, and no event handler captured it), the object — and everything reachable only through it — is now unreachable.
Step 3 — A collection happens, triggered by allocation pressure
The GC does not run on a fixed timer, and it does not run the instant an object becomes unreachable. It runs when the runtime decides memory pressure warrants it — most commonly, when a generation of the heap (covered in the next lesson) has accumulated enough new allocations to be worth examining. This is a deliberately non-deterministic point in time from your code's perspective: you cannot reliably predict exactly when a given unreachable object's memory will actually be reclaimed.
Step 4 — The GC traces from roots and identifies survivors
When a collection runs, the GC (conceptually) walks the object graph starting at every current root, marking every object it finds along the way as "still alive." This is the mark phase of a mark-and-sweep-style approach: mark what's reachable, then treat everything unmarked as reclaimable.
Step 5 — Unreachable memory is reclaimed, and live memory is often compacted
Objects with no path back to any root are garbage — their memory is freed. .NET's GC typically also compacts the surviving objects (moves them together, eliminating gaps left by the reclaimed ones), which keeps future allocation fast and avoids fragmentation. You'll see exactly why compaction matters for allocation speed in the memory allocation lesson later in this module.
Simple Example
public class Report
{
public string Title { get; set; } = "";
}
Report CreateReport()
{
var report = new Report { Title = "Q3 Summary" }; // allocated on the heap
return report;
}
void RunReport()
{
var r = CreateReport(); // r now holds the only reference
Console.WriteLine(r.Title);
// r goes out of scope here when RunReport returns
// From this point on, the Report object is unreachable
// — eventually, a GC will reclaim it. Exactly when is not
// something your code can rely on or observe directly.
}
Code → Meaning → Result:
- Inside
CreateReport, the newReportis reachable through the local variablereport, and then through the returned reference stored inr. - Once
RunReportreturns,rno longer exists as a root, and (assuming nothing else references it) theReportobject becomes garbage. - Nothing in this code explicitly frees anything — that's the entire point. The GC will reclaim it on some future collection, whenever memory pressure makes that worthwhile.
Real-World Example
Consider an ASP.NET Core API handling thousands of requests per minute. Every request typically allocates a burst of short-lived objects: a request DTO, a few EF Core entity instances, a result object, maybe some LINQ intermediate collections. Once the response is written and the request completes, every one of those objects instantly becomes unreachable — nothing in the app still references them.
Over the life of the process, this produces an enormous, continuous churn of allocate-then-discard. The GC is what makes this workable at all: without it, the server would need to explicitly track and free every one of those thousands of small, per-request objects, correctly, every single time, with zero mistakes — an essentially impossible bar to hold in a codebase with any real complexity. The GC instead lets developers focus entirely on business logic, while it continuously reclaims the constant stream of request-scoped garbage in the background.
This also explains why .NET provides concurrent (background) GC as the default mode for server workloads: a request-serving process cannot tolerate the entire application freezing for a long pause every time memory needs reclaiming, so much of the GC's work happens on a background thread, concurrently with your application continuing to run.
Analogy
Manual memory management is like living in a house where you personally have to carry every piece of trash out to the curb the moment you're done with it — forget one item, and it piles up forever (a leak); throw something away that a roommate is still using, and chaos follows (use-after-free); try to throw the same bag out twice and you'll trip over your own confusion (double-free).
The GC is a cleaning crew that periodically walks through the house, checks what's genuinely still in use — is anyone sitting on this chair, is this book on anyone's active reading list — and only removes what's truly abandoned. You never have to remember to take anything out yourself. The trade-off: the crew needs to periodically walk through and check, and while they're actively working in a room, that can briefly get in the way of what you're doing there — which is exactly why modern GC designs try hard to do most of that checking without making you stop and wait.
Under the Hood
- The GC tracks how much has been allocated since the last collection of each generation (generations are next lesson's topic).
- When that amount crosses an internal budget, a collection is triggered — this is why allocation-heavy code can trigger collections more often than allocation-light code, all else equal.
- There is no fixed interval — a program that allocates almost nothing might go minutes without a single collection.
- To safely trace what's reachable, the GC needs a consistent snapshot of what every root currently points to — references can't be mutated by your program's threads mid-trace, or the trace could miss something or follow a dangling pointer.
- Historically, this meant "stop the world" — briefly pause every managed thread while tracing (and especially while compacting) happens.
- Even modern designs still need brief, tightly scoped stop-the-world moments for certain phases — the goal has been to shrink and minimize these, not eliminate them completely.
- Modern .NET runs much of the tracing work for the oldest, largest generation concurrently on a background thread, while your application's threads keep running.
- This dramatically shrinks the duration of the actual stop-the-world portions, at the cost of some additional bookkeeping (a "write barrier" — extra, cheap work the runtime does whenever a reference field is updated, so the background collector can be told about changes it needs to know about).
- It's honest to say this reduces pause times significantly, not that it makes pauses vanish entirely — brief, small pauses remain part of how the GC works.
- Mark: trace from roots, flag every reachable object.
- Sweep / reclaim: everything unflagged is treated as free space.
- .NET's actual GC (with generations, compaction, and background collection) is considerably more sophisticated than a textbook mark-and-sweep implementation, but this is the right conceptual model to build on before the generational details in the next lesson.
Common Confusion
1. "The GC frees memory the instant an object goes out of scope"
It doesn't. Going out of scope (or setting a reference to null) makes an object eligible for collection — it removes the object from the reachable set. But the actual reclamation only happens the next time a collection runs against the generation that object lives in, which could be milliseconds later or, if memory pressure is low, considerably longer. Reachability and reclamation are two separate events.
2. "The .NET GC uses reference counting, like some other languages"
No — .NET's GC is a tracing collector, not a reference-counting one. Reference counting (incrementing/decrementing a counter on every assignment) has its own well-known weakness: two objects that reference each other, but that nothing else references, would keep each other's count above zero forever under naive reference counting — a reference cycle that leaks. A tracing collector like .NET's has no such problem: if neither object is reachable from an actual root, both are correctly identified as garbage, cycle or not.
3. "Garbage collection means .NET can't leak memory"
The GC eliminates use-after-free and double-free bugs, but it cannot eliminate leaks caused by your own code accidentally keeping something reachable forever — a static collection you keep adding to and never clear, an event subscription that's never unsubscribed, a cache with no eviction policy. If a root still points to it, directly or transitively, the GC is doing exactly its job by keeping it alive. "Managed memory leak" is a real, common category of production bug — it just has a different root cause than in unmanaged languages.
Common Mistakes
Mistake 1 — Calling GC.Collect() manually "to be safe"
Sprinkling GC.Collect() throughout application code on the assumption it will improve performance or memory usage.
In almost every real scenario, this makes things worse: it forces a full, synchronous collection at a moment the runtime's own heuristics would not have chosen, interrupting work that was proceeding fine. Trust the GC's own scheduling; it has far more information about actual memory pressure than a single call site does.
Mistake 2 — Assuming an object is gone just because you set the variable to null
Writing myObject = null; and treating that as equivalent to memory being immediately freed.
Setting a reference to null removes one path to the object — it makes the object unreachable if that was the only path. The memory itself is reclaimed later, whenever a collection actually runs. For deterministic, immediate resource cleanup (file handles, network sockets), C#'s IDisposable/using pattern — which you already learned in earlier tiers — is the correct tool, not relying on GC timing.
Mistake 3 — Diagnosing "memory keeps growing" as a GC bug rather than a rooted reference
Assuming rising memory usage means the garbage collector "isn't working" or is somehow broken.
Almost always, growing managed memory means something is still reachable that you didn't intend to keep alive — a forgotten event subscription, an ever-growing static cache, a captured closure holding a reference longer than expected. The fix is finding and breaking that reference chain, not distrusting the GC itself.
Why Does This Matter for My Code?
You can't turn the GC off for ordinary managed objects, and you almost never should try to. What this lesson changes is how you reason about memory-related symptoms:
- When memory grows unexpectedly, you now know to ask "what's still rooting this?" rather than "why isn't the GC running?"
- When you see occasional latency spikes in a server app, you know to consider GC pauses as one real, legitimate hypothesis — and you know concurrent GC exists specifically to minimize (not eliminate) that class of spike.
- You understand precisely why
IDisposableexists alongside the GC: the GC handles memory, but it has no idea a file handle or network connection needs to be released promptly — that's still your explicit responsibility. - The next lesson builds directly on this one: you'll learn why .NET doesn't trace the entire heap on every collection, and how "most objects die young" shapes the GC's whole design.
IDisposable/using for unmanaged or scarce resources (files, sockets, database connections), never for ordinary heap-allocated objects.
Mental Model
Reachable = something your program could still, in principle, get to from a root.
Garbage = reachable from nowhere — provably, permanently unusable to your program.
Remember: going out of scope makes something eligible; a later collection actually reclaims it. Those are two different moments in time, and the gap between them is intentional, not a bug.
Key Takeaway
- Manual memory management is dangerous — leaks, use-after-free, and double-free bugs are common and hard to diagnose. The GC exists to eliminate those failure modes structurally.
- The GC is a tracing, reachability-based collector: it walks the object graph from known roots (stack, statics, registers) and reclaims only what's provably unreachable.
- Collections are triggered by allocation pressure, not a timer — you cannot reliably predict exactly when a specific object's memory will be reclaimed.
- Pauses exist because tracing (and compaction) need a consistent snapshot of references; concurrent/background GC significantly reduces — but does not eliminate — those pauses in modern .NET.
- The GC prevents use-after-free/double-free, but it cannot prevent a logical leak — an object your own code keeps unnecessarily reachable. That's still on you to avoid.
Check Your Understanding
You've learned what the GC is, why it exists, and how it decides what's garbage. Let's test the core mental model.
1. What does it mean for an object to be a "GC root"?
Show answer
Correct: B
Why B is correct: Roots are the known, trusted starting points the GC uses to begin its trace — local variables on the stack, static fields, and CPU registers holding references — not heap objects themselves. Anything reachable by following references starting from a root is considered live.
Why A is incorrect: Roots aren't defined by allocation order — a variable declared near the very end of a method's execution is just as much a root as one declared earlier.
Why C is incorrect: Survival count relates to generations, a topic in the next lesson — it's unrelated to what makes something a root.
Why D is incorrect: Object size relates to the Large Object Heap, a separate concept covered in the next lesson — it has nothing to do with being a root.
Reinforcement: Roots are the "known safe" starting points; everything else's status (reachable or garbage) is determined by whether a chain of references connects it back to one.
2. A local variable holding a reference to an object goes out of scope when its containing method returns, and nothing else in the program references that object. What happens immediately at that moment?
Show answer
Correct: B
Why B is correct: Going out of scope removes a root, which can make the object unreachable — but reclaiming the memory is a separate, later event that only happens when a collection actually runs and traces the heap.
Why A is incorrect: There's no synchronous, instant reclamation tied to scope exit — this is precisely the common misconception the lesson called out directly.
Why C is incorrect: Collections happen throughout a process's lifetime, triggered by allocation pressure — not only at process exit.
Why D is incorrect: There's no "ownerless object" exception in the CLR's memory model — unreachable objects simply become collectible, silently, with no exception involved.
Reinforcement: Eligibility (a state) and reclamation (an event) are distinct — this is the single most important timing distinction in this lesson.
3. Object A holds a reference to Object B, and Object B holds a reference back to Object A — but nothing else in the running program references either of them. What does .NET's tracing garbage collector conclude?
Show answer
Correct: B
Why B is correct: Because .NET's GC is a tracing collector, not a reference-counting one, it doesn't care how many references point at an object — only whether a real root can reach it. Since neither A nor B is reachable from any root, the trace never marks either as live, and both are correctly reclaimed together.
Why A is incorrect: This describes a real weakness of naive reference-counting garbage collectors — but that's explicitly not the algorithm .NET's GC uses, which is why this scenario isn't a problem for it.
Why C is incorrect: Reference cycles are an entirely normal, expected pattern (e.g., parent-child relationships) that tracing GCs handle correctly with no special exception or error.
Why D is incorrect: Reachability, not creation order, determines collectability — creation order plays no role in this decision at all.
Reinforcement: This is exactly why the lesson emphasized "tracing, not reference-counting" — cyclic references are one of the classic problem cases reference counting handles poorly, and one that tracing collectors like .NET's handle correctly by design.
4. A developer notices their long-running service's memory usage keeps climbing steadily over several days and suspects "the garbage collector isn't working." What is the most likely actual explanation, based on this lesson?
Show answer
Correct: B
Why B is correct: The GC only reclaims what's unreachable — if something in the application logic (a growing cache with no eviction, a forgotten event subscription) keeps objects rooted, the GC is behaving exactly correctly by refusing to collect them. This is the "managed memory leak" pattern described in Common Confusion.
Why A is incorrect: The GC is a mature, extensively used component of the runtime — steadily growing memory in a real application is overwhelmingly a rooted-reference issue in application code, not a runtime defect.
Why C is incorrect: There's no inherent time limit on a .NET process's healthy lifetime — well-written services run for extended periods without memory issues; steady growth points to a specific, findable rooting bug, not an inherent platform limitation.
Why D is incorrect: The GC runs continuously throughout a process's life, triggered by allocation pressure as needed — it is not a one-time startup operation.
Reinforcement: The right diagnostic question for growing memory is always "what's still referencing this?" — a memory profiler that shows retained object graphs and their root paths is the standard tool for answering it.
5. Why does concurrent (background) GC exist as the default for typical server workloads in modern .NET?
Show answer
Correct: B
Why B is correct: Concurrent GC allows a substantial portion of the collector's work to happen on a background thread while the application keeps running, shrinking the duration of the unavoidable stop-the-world portions — this is exactly why it matters for latency-sensitive, request-serving workloads.
Why A is incorrect: The lesson was explicit that pauses are reduced, not eliminated — some brief stop-the-world work remains necessary even with concurrent collection.
Why C is incorrect: The GC continues to run during active request handling — concurrent GC is about overlapping its work with the application, not pausing collection while requests are served.
Why D is incorrect: .NET's GC remains a tracing collector throughout — concurrent GC changes when and how tracing work is scheduled relative to application threads, not the fundamental algorithm used to determine reachability.
Reinforcement: "Reduce pauses" is the accurate, defensible claim about concurrent GC — treating it as "pauses are gone" leads to false confidence when diagnosing real latency spikes in production.
You now understand why the GC exists and how it decides what's safe to reclaim — next, you'll see how .NET makes this fast in practice with generations.