Demystifying .NET Garbage Collection
How the CLR Quietly Manages Memory So You Can Focus on Building Software
If you started programming with C or C++, you probably remember how much responsibility came with memory management.
Every object you created had to be released manually.
Forget to free it?
You leaked memory.
Free it too early?
You were looking at crashes, dangling pointers, and hours of debugging.
Managing memory wasn’t just another task.
It was part of writing correct software.
Then .NET changed the game.
Instead of asking developers to constantly think about memory allocation, Microsoft introduced the Garbage Collector (GC)—a system that automatically cleans up memory that is no longer being used.
At first glance, it feels almost magical.
Create objects.
Use them.
Forget about them.
But the Garbage Collector isn’t magic.
It’s a carefully designed system with clear rules, trade-offs, and performance implications.
Understanding those rules is what separates someone who simply writes .NET code from someone who understands how the runtime actually works.
Meet the Real Manager: The CLR
Every .NET application runs inside the Common Language Runtime (CLR).
Think of the CLR as the operating environment for your application.
It compiles code, manages execution, handles exceptions, enforces type safety, and—most importantly for this discussion—takes care of memory management.
The Garbage Collector is one part of the CLR.
Its job is simple:
Find memory that is no longer needed and reclaim it safely.
Contrary to what many people imagine, the GC isn’t constantly watching every object.
Instead, it wakes up only when necessary.
Maybe the application has allocated enough new objects.
Maybe memory pressure has increased.
Or maybe a collection was explicitly requested.
When that happens, the GC pauses, examines memory, cleans up what is no longer reachable, and lets the application continue.
Most of the time, you never notice it happening.
And that’s exactly the point.
Two Different Worlds: Managed Memory and External Resources
One of the biggest misconceptions about the Garbage Collector is believing it cleans up everything.
It doesn’t.
The GC only understands managed memory.
Imagine your application as an office.
Every time you create an object, it’s like placing a sheet of paper on your desk.
Eventually those papers become useless.
The cleaning staff walks through the office, throws away the unused paper, and keeps everything tidy.
That’s the Garbage Collector.
Now imagine something different.
Instead of paper, you borrowed the key to a secure vault across the street.
The office cleaning staff can’t return that key for you.
It exists outside the office.
That’s exactly how files, database connections, sockets, operating system handles, and many other resources work.
These are unmanaged resources.
The Garbage Collector can reclaim the object that points to them.
But it cannot release the underlying operating system resource.
That’s why .NET introduced IDisposable.
It’s your way of saying,
“I’m finished with this external resource. You can release it now.”
Why Dispose() Exists
When an object owns something outside managed memory, waiting for the Garbage Collector is usually too late.
Imagine opening a file.
Or holding a SQL connection.
Or locking a resource.
Technically, the GC will eventually remove the object.
But until that happens, the operating system still believes the resource is in use.
That’s why using exists.
It guarantees that Dispose() is called immediately when the object leaves scope—even if an exception occurs.
In modern C#, this often looks like:
using var stream = File.OpenRead("report.pdf");
Simple.
Predictable.
Safe.
Whenever you create a disposable object, you are generally responsible for disposing it.
Ownership matters more than the object itself.
But Don’t Dispose Everything
This is where many developers become overly cautious.
They learn about Dispose() and start calling it everywhere.
Ironically, that can introduce new problems.
If you didn’t create the object, you usually shouldn’t destroy it.
Consider shared objects provided by the framework.
Something like Brushes.Blue exists for the entire application’s lifetime.
Disposing it would affect every other piece of code using the same shared instance.
The same idea applies to objects managed by dependency injection.
If the framework created the object, let the framework clean it up.
Another subtle example involves streams.
Disposing a StreamReader also disposes the underlying stream by default.
Sometimes that’s exactly what you want.
Other times, you still need that stream afterward.
Understanding ownership is more important than memorizing rules.
How the Garbage Collector Actually Cleans Memory
The Garbage Collector follows a process often described as Mark and Compact.
First, it identifies every object that is still reachable.
These objects are considered alive.
Anything that cannot be reached is treated as garbage.
Those objects are removed.
Finally, the remaining objects are packed together to eliminate gaps in memory.
Think about a bookshelf.
Over time, people remove books from random places.
Eventually there are empty spaces everywhere.
Instead of leaving scattered gaps, someone slides all the remaining books together.
Now there’s one clean block of free space at the end.
Memory works the same way.
Keeping memory compact allows new objects to be allocated extremely quickly.
The Objects That Never Disappear
The Garbage Collector doesn’t guess which objects are important.
It starts from a set of known references called GC Roots.
Anything reachable from these roots stays alive.
Typical roots include:
Local variables currently in use
Method parameters
Static fields
Objects waiting in the finalization queue
If no root can reach an object anymore, it becomes eligible for collection.
This simple rule explains many memory leaks.
Often the object itself isn’t the problem.
Some forgotten static collection is still holding a reference to it.
As long as that reference exists, the GC assumes the object is still needed.
Why the GC Uses Generations
Most objects don’t live very long.
A temporary string.
A LINQ result.
A DTO created for one request.
They’re born.
Used.
Then forgotten.
The .NET runtime takes advantage of this observation.
Instead of scanning the entire heap every time, memory is divided into generations.
New objects begin in Generation 0.
If they survive, they’re promoted to Generation 1.
Objects that continue living eventually reach Generation 2, where long-lived objects reside.
This makes collections dramatically faster.
Instead of checking everything, the runtime usually focuses only on the newest objects.
That’s why reducing unnecessary long-lived allocations is often one of the biggest performance improvements you can make.
Finalizers: The Emergency Backup
A finalizer is often misunderstood.
It’s not the normal cleanup mechanism.
It’s the emergency recovery plan.
Imagine renting a car.
Normally, you return the keys yourself.
That’s Dispose().
If you disappear without returning the car, the rental company eventually sends someone to recover it.
That’s the finalizer.
Useful?
Absolutely.
Efficient?
Not at all.
Objects with finalizers survive at least one additional GC cycle before they can be reclaimed.
They also rely on a single background thread.
If finalizers become slow, memory cleanup slows down too.
For most application code, you should never need a finalizer.
They exist primarily for types that directly manage unmanaged operating system resources.
Dependency Injection Changed the Rules
Modern ASP.NET Core applications rarely create important objects manually.
Instead, the Dependency Injection container creates them.
It tracks them.
And it disposes them automatically.
Take DbContext as an example.
You inject it into a service.
Use it.
Call SaveChangesAsync().
Then simply return your response.
When the HTTP request finishes, ASP.NET Core automatically disposes the context.
Adding your own using block around an injected DbContext doesn’t improve resource management.
It actually breaks the lifecycle that the container is already managing.
The guiding principle is surprisingly simple:
Whoever creates the object is responsible for destroying it.
If the framework created it, trust the framework.
Final Thoughts
The Garbage Collector doesn’t eliminate the need to think about memory.
It changes what you need to think about.
Instead of worrying about every allocation and deallocation, you think about object lifetimes, ownership, and resource management.
The GC handles RAM.
You handle external resources.
The runtime manages memory.
You manage architecture.
Once you understand that distinction, many confusing .NET practices suddenly make perfect sense.
The goal isn’t to fight the Garbage Collector.
It’s to work with it.
Because well-designed software isn’t built by managing memory manually.
It’s built by understanding when the runtime is already doing the right thing for you.




