Memory management is one of those things developers often ignore - until something breaks. In C#, thanks to the .NET runtime, you don’t have to manually allocate and free memory like in C or C++. But that doesn’t mean you shouldn’t understand how it works. In fact, knowing how garbage collection (GC) works in .NET can help you write faster, safer, and more memory-efficient applications.
This article walks through everything you need to know about garbage collection in C#: from the basics of how memory is allocated, to advanced topics like generations, finalizers, IDisposable, memory pressure, and performance tuning. We’ll also dive into real-world troubleshooting examples - the kind of GC issues that show up in production and how to fix them.
What Is Garbage Collection?
Garbage collection is the process of automatically reclaiming memory that your program no longer needs. In C#,
when you create an object using new
, it’s allocated on the heap. When that object
is no longer referenced by any part of your code, the garbage collector steps in and frees that memory.
Stack vs Heap - Quick Refresher
Before diving deeper, let’s quickly revisit how memory is organized:
- Stack: Used for value types and method calls. Fast and automatically cleaned up when a method returns.
- Heap: Used for reference types (like classes). Managed by the garbage collector.
So when you write var person = new Person()
, that Person
object lives on the heap. The variable person
is just a reference on the stack.
How Does the GC Know What to Collect?
The GC uses a technique called reachability analysis. It starts from a set of “roots” - like static variables, local variables in active methods, and CPU registers - and walks through all references. Anything it can’t reach is considered garbage.
public class Person
{
public string Name;
}
void Demo()
{
var p = new Person();
p.Name = "Vaibhav";
} // After this method ends, 'p' is no longer reachable
Once Demo()
finishes, the reference to p
is
gone. If no other part of the program holds a reference to that Person
object, it
becomes eligible for collection.
Generations - The Secret Sauce
.NET’s garbage collector is generational. That means it divides objects into three generations:
- Gen 0: Newly created objects. Collected frequently.
- Gen 1: Survived one collection. Mid-life.
- Gen 2: Long-lived objects. Collected less often.
The idea is simple: most objects die young. So Gen 0 collections are fast and frequent. Gen 2 collections are expensive and rare.
object.ReferenceEquals("hello", "hello")
returns true.
Finalizers and the IDisposable
Pattern
Sometimes, your objects hold unmanaged resources - like file handles, database connections, or native memory. The
GC doesn’t know how to clean those up. That’s where finalizers and IDisposable
come in.
public class FileLogger : IDisposable
{
private StreamWriter _writer;
public FileLogger(string path)
{
_writer = new StreamWriter(path);
}
public void Log(string message)
{
_writer.WriteLine(message);
}
public void Dispose()
{
_writer?.Dispose();
GC.SuppressFinalize(this);
}
~FileLogger()
{
Dispose();
}
}
By implementing IDisposable
, you give consumers of your class a way to clean up
resources manually. This is especially important for things like database connections or file streams.
using
or await using
with disposable
objects. It ensures timely cleanup and avoids memory
leaks.
Large Object Heap (LOH)
Objects larger than 85,000 bytes go into the Large Object Heap. LOH is part of Gen 2 and is collected less frequently. If you allocate lots of large arrays or buffers, be mindful of LOH fragmentation.
byte[] buffer = new byte[100_000]; // Goes to LOH
LOH doesn’t compact memory by default, which can lead to fragmentation. .NET 4.5+ introduced LOH compaction, but it’s opt-in.
Memory Pressure and GC.AddMemoryPressure()
If your object wraps unmanaged memory (like a native buffer), you can inform the GC using:
GC.AddMemoryPressure(sizeInBytes);
This helps the GC make better decisions about when to collect. Don’t forget to call GC.RemoveMemoryPressure()
when done.
Monitoring GC Behavior
You can inspect GC stats using:
GC.GetTotalMemory(false);
GC.CollectionCount(0); // Gen 0
GC.CollectionCount(1); // Gen 1
GC.CollectionCount(2); // Gen 2
For deeper insights, use tools like:
- dotMemory
- PerfView
- Visual Studio Diagnostic Tools
- JetBrains Rider Memory Profiler
GC Modes - Workstation vs Server
.NET offers two GC modes:
- Workstation GC: Optimized for desktop apps. Prioritizes responsiveness.
- Server GC: Optimized for throughput. Uses multiple threads. Ideal for web apps and services.
You can configure GC mode in your runtimeconfig.json
or app.config
.
Real-World GC Troubleshooting Examples
Let’s get practical. Here are some real-world scenarios where garbage collection caused issues - and how developers diagnosed and fixed them.
1. Memory Leak from Event Handlers
A WPF app was consuming more memory over time, even though no large objects were being created. The culprit? Event handlers.
public class ViewModel
{
public ViewModel()
{
SomeService.SomeEvent += HandleEvent;
}
private void HandleEvent(object sender, EventArgs e) { }
}
The ViewModel
was never collected because SomeService
held a reference via the event. Fix? Unsubscribe when done:
SomeService.SomeEvent -= HandleEvent;
2. LOH Fragmentation in Image Processing
A desktop app that processed large images started throwing OutOfMemoryException
after a few hours. The issue? LOH fragmentation.
Each image was ~100KB, so it landed in the Large Object Heap. Over time, the LOH became fragmented, and even though there was enough memory, no contiguous block was available.
Fixes included:
- Reusing buffers instead of allocating new ones
- Switching to pooled memory via
ArrayPool<T>
- Enabling LOH compaction in .NET Core
3. GC Pressure in High-Throughput APIs
A web API was experiencing latency spikes every few minutes. Profiling showed Gen 2 collections were happening frequently. Why? Too many short-lived allocations.
The team was creating new objects for every request - including string concatenations, JSON parsing, and DTOs. Fixes included:
- Using
StringBuilder
instead of string concatenation - Reusing DTOs via object pooling
- Reducing allocations in hot paths
4. Finalizer Queue Delays
A service was leaking file handles. Investigation showed that objects with finalizers were piling up in the finalizer queue, but not being collected fast enough.
The fix? Implement IDisposable
properly and call Dispose()
manually. Relying on finalizers alone was too slow.
5. Async Closures Holding Memory
A background task was holding onto a large buffer longer than expected. Why? It was captured in an async lambda.
byte[] buffer = new byte[100_000];
await Task.Run(() => Console.WriteLine(buffer.Length));
Even though the buffer wasn’t needed after the lambda, it stayed alive until the task completed. Fix? Avoid capturing large objects in closures.
Summary
Garbage collection in C# is powerful, but not magic. It frees you from manual memory management, but you still need to understand how it works. From generations to finalizers, from LOH to memory pressure - every piece plays a role in keeping your app fast and efficient.
The key takeaway? Respect the GC. Use IDisposable
properly, avoid unnecessary
finalizers, and let the runtime do its job. And when performance matters, measure - don’t guess.
In the next article, we’ll explore how value types and reference types behave differently in memory - and how that affects performance, copying, and equality. Stay tuned!