.NET is a managed runtime, so memory is handled for you, which is exactly why the exceptions to that rule cause trouble. Files stay locked, database connection pools run dry, and sockets pile up in TIME_WAIT, all in a language where “you do not have to free memory” is technically true. This post explains what the garbage collector does, what it deliberately does not do, and why IDisposable and using exist to cover the gap. It builds on the value types post and pairs with the leak from the events post.
What the GC does
The garbage collector reclaims managed memory, objects you created with new on the heap. It works by reachability, not by counting references:
- It pauses managed threads.
- It starts from a set of roots: static fields, local variables on every thread’s stack, CPU registers, and pending finalizers.
- Anything reachable by following references from a root is live. Everything else is garbage, whatever it points to.
- It reclaims the garbage and compacts the survivors, so the heap does not fragment.
Reachability is the important word. Two objects referencing each other with nothing else pointing at them are both garbage: .NET has no circular-reference problem, unlike naive reference counting. And an object nobody uses but a static list still holds is live, which is the shape of nearly every .NET memory leak.
Generations make it fast. The GC exploits the observation that most objects die young:
- Gen 0: newly allocated objects. Collected often and very cheaply, because almost everything in it is already dead.
- Gen 1: survived one collection. A buffer between short- and long-lived.
- Gen 2: survived several. Collected rarely, and a full Gen 2 collection is the expensive one.
- The Large Object Heap (LOH): allocations over 85,000 bytes, collected with Gen 2 and, by default, not compacted. Large arrays and big strings land here, and repeated large allocations fragment it.
Practical consequences: allocating short-lived objects is genuinely cheap (a pointer bump, then a cheap Gen 0 sweep). What costs you is objects that survive: they get promoted, and then only a Gen 2 collection can reclaim them. Caches, static collections and long-lived buffers are what turn GC into a performance problem, not allocation volume by itself.
What the GC does not do
The GC only knows about managed memory. It knows nothing about:
- File handles: the OS keeps the file locked
- Database connections: the pool has a hard limit and does not know you have finished
- Sockets and HTTP connections
- Locks, mutexes, semaphores
- Native memory allocated by an unmanaged library
- Timers and event subscriptions that keep objects alive
These are unmanaged resources. They are scarce, they are held by the operating system, and nothing reclaims them automatically at a useful time. Worse, the GC’s timing is unpredictable by design: it runs when memory pressure demands it, not when your file handle count is high. A program can hold ten thousand open files while using barely any managed memory, so the GC never runs, so nothing gets released.
That is the gap IDisposable fills: a standard way to say “I am finished with this, release the resource now”.
using
public interface IDisposable
{
void Dispose();
}
One method. The using statement guarantees it is called:
using (var reader = new StreamReader("books.csv"))
{
var line = reader.ReadLine();
} // Dispose() called here, even if ReadLine throws
That compiles into a try/finally, which is why it works even on an exception path. Since C# 8, the declaration form disposes at the end of the enclosing scope, which is cleaner for the common case:
public string ReadFirstLine(string path)
{
using var reader = new StreamReader(path);
return reader.ReadLine() ?? "";
} // disposed here
The rule is simple: if a type implements IDisposable, use using. No exceptions worth remembering, and analysers will flag you when you forget. The common types are StreamReader/Writer, FileStream, SqlConnection, DbContext, HttpResponseMessage, CancellationTokenSource, Timer, and any lock scope.
The famous counter-example is HttpClient, which is disposable and which you should not create and dispose per request: disposing it closes the underlying socket, and sockets linger in TIME_WAIT, so a busy service exhausts ports. Use a single instance or IHttpClientFactory. It is worth knowing as the exception that proves how much the rule matters.
Writing a disposable type
Most of the time this is all you need, a class holding other disposables:
public sealed class BookExporter : IDisposable
{
private readonly StreamWriter _writer;
private bool _disposed;
public BookExporter(string path) => _writer = new StreamWriter(path);
public void Dispose()
{
if (_disposed) return; // Dispose must be safe to call twice
_writer.Dispose();
_disposed = true;
}
}
Two things to note. Sealed, because you are not designing for inheritance and that lets you skip the full pattern. And idempotent: Dispose may be called more than once, and must not throw the second time.
The full dispose pattern is only needed when your class directly owns unmanaged resources (a raw handle from P/Invoke) or is unsealed and may be inherited:
public class NativeBuffer : IDisposable
{
private IntPtr _handle;
private bool _disposed;
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
// release managed disposables here, safe, we are not on the finalizer thread
}
if (_handle != IntPtr.Zero) // release unmanaged resources always
{
Native.Free(_handle);
_handle = IntPtr.Zero;
}
_disposed = true;
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this); // we cleaned up; skip the finalizer
}
~NativeBuffer() => Dispose(disposing: false); // safety net only
}
In modern C# you rarely write this: SafeHandle wraps native handles and handles finalization correctly for you, so a class holding a SafeHandle needs only the simple version above.
Finalizers, and why to avoid them
A finalizer (~ClassName) is the runtime’s last resort, code that runs before an object’s memory is reclaimed if nobody disposed it. The costs are real:
- An object with a finalizer survives its first collection. It goes on the finalization queue, gets promoted to the next generation, and is only collected on a later pass. You have doubled its lifetime and pushed it into a more expensive generation.
- Finalizers run on a single dedicated thread, in no guaranteed order, at no guaranteed time. One that blocks stalls finalization process-wide.
- On shutdown they may not run at all.
So: never write a finalizer to release managed resources: the objects they point to are being collected anyway, and touching them from the finalizer thread is unsafe. Write one only as a backstop for a raw unmanaged handle, and call GC.SuppressFinalize(this) in Dispose so the finalizer is skipped when disposal happened properly. Better still, use SafeHandle and write none.
await using and IAsyncDisposable are the async equivalents, for types whose cleanup does I/O, flushing a stream, closing a connection gracefully:
await using var connection = new SqlConnection(cs);
The leaks that actually happen
Managed languages do leak. Every one of these is an object staying reachable longer than intended:
Static collections. A static Dictionary<int, Book> used as a cache with no eviction grows until the process dies. The most common .NET leak by a wide margin.
Event handlers. As the events post covered, the publisher holds a strong reference to every subscriber. A long-lived publisher keeps short-lived subscribers alive forever. Unsubscribe in Dispose.
Undisposed disposables. Not a managed leak, but the more urgent one: connection pool exhaustion produces timeouts under load and looks like a database problem.
Captured closures. A lambda captures its whole enclosing scope, so a long-lived delegate (a cached Func, a timer callback, an event handler) can keep a large object graph alive through a variable you forgot was in scope.
Timers. A running System.Threading.Timer roots its callback and therefore its target object. Dispose the timer.
Long-lived DbContext. Change tracking accumulates every entity it has seen. A context should be scoped to a request or a unit of work, which is what the default DI registration does.
And one non-fix: GC.Collect(). Do not call it. It forces a full blocking collection, discards the runtime’s tuning heuristics, promotes surviving objects, and typically makes things slower. If you think you need it, you have a reachability problem, and the answer is a memory profiler.
The same idea inside Java, Rust and Go
Java (Netflix, Android). Also generational and tracing, with the same distinction between memory and resources: try-with-resources and AutoCloseable are using and IDisposable under different names. Java deprecated finalize() outright in Java 9 for the reasons above: .NET keeps finalizers but discourages them just as strongly.
Go (Google). Concurrent tracing collector, no generations, tuned for very low pause times. The same discipline appears as defer file.Close(), which puts the cleanup next to the acquisition, arguably clearer than using, and the same idea.
Python. Reference counting plus a cycle collector, so objects are usually freed immediately when the last reference disappears, and with blocks handle resources deterministically. The trade is that reference counting costs on every assignment.
Rust. No garbage collector at all. Ownership decides at compile time when a value dies, and Drop::drop runs at that exact point. It is Dispose made automatic, mandatory and free, and the strongest argument that deterministic cleanup is what you want for both memory and resources, not just resources.
C++. RAII, which is where the whole idea came from: a destructor runs at end of scope, deterministically, for every resource. using is C#‘s way of getting RAII back for the cases where non-deterministic collection is not good enough.
Summary
- The GC reclaims managed memory by reachability from roots, so cycles are collected but anything a static field holds is alive.
- Generations make collection cheap for short-lived objects; objects that survive get promoted, and Gen 2 collections are the expensive ones. Allocations over ~85 KB go to the Large Object Heap.
- The GC does not release unmanaged resources (file handles, sockets, database connections, locks, native memory), and its timing is unpredictable, so waiting for it is not an option.
IDisposable.Dispose()is the “release it now” contract, andusingguarantees the call via a compiledtry/finally. If a type is disposable, useusing.Disposemust be idempotent. A simple sealed class disposing its fields is usually all you need.- The full dispose pattern with
Dispose(bool)is for unsealed types or direct unmanaged handles; preferSafeHandleand avoid finalizers, which delay collection and run on one shared thread at an unpredictable time. - Call
GC.SuppressFinalize(this)inDisposeif the type has a finalizer. - Real .NET leaks come from reachability: static caches, event subscriptions, captured closures, timers and long-lived
DbContextinstances. Do not callGC.Collect(); use a profiler.
Interview questions and answers
1. How does the .NET garbage collector decide what to collect?
By reachability. It suspends managed threads, walks the object graph from roots (static fields, thread stacks, registers and the finalization queue) and marks everything reachable as live. Anything unreachable is collected, so mutually referencing objects with no external reference are reclaimed correctly.
2. What are GC generations and why do they exist?
Gen 0 holds new objects, Gen 1 objects that survived one collection, Gen 2 long-lived objects. Most objects die young, so collecting only Gen 0 reclaims most garbage very cheaply. Survivors are promoted, and only occasional expensive Gen 2 collections examine the whole heap.
3. What is the Large Object Heap?
A separate area for allocations of roughly 85,000 bytes or more, large arrays and strings. It is collected along with Gen 2 and is not compacted by default, so repeated large allocations fragment it and can cause out-of-memory conditions even with free space available. Pooling large buffers with ArrayPool<T> avoids the problem.
4. Why is IDisposable needed if there is a garbage collector?
Because the GC manages memory only, and it runs at an unpredictable time driven by memory pressure. Unmanaged resources such as file handles, sockets and database connections are scarce, held by the operating system, and must be released promptly. Dispose provides a deterministic point at which to do that.
5. What does a using statement compile into?
A try/finally where the finally calls Dispose() on the resource, so cleanup happens on both the normal and the exception path. The C# 8 using var declaration form does the same thing, disposing at the end of the enclosing scope rather than a nested block.
6. What is the dispose pattern and when do you need it?
A protected virtual void Dispose(bool disposing) method plus a public Dispose() that calls it with true and then GC.SuppressFinalize(this), optionally with a finalizer calling it with false. It is only needed for unsealed classes intended for inheritance, or classes directly owning raw unmanaged handles. A sealed class holding managed disposables just needs a simple idempotent Dispose.
7. What is a finalizer and why should you avoid it?
A ~ClassName() method the runtime calls before reclaiming an object that was never disposed. It forces the object to survive an extra collection and be promoted, it runs on a single shared thread with no ordering or timing guarantee, and it may not run at shutdown. Use SafeHandle instead, and where a finalizer is unavoidable, suppress it in Dispose.
8. What does GC.SuppressFinalize do?
It removes the object from the finalization queue, telling the runtime the finalizer no longer needs to run because Dispose has already released everything. Without it, a disposed object with a finalizer still pays the extra collection and promotion cost.
9. Can a managed application leak memory?
Yes, whenever objects stay reachable longer than intended. The usual causes are static collections and caches without eviction, event handlers that were never unsubscribed, closures capturing more than expected, undisposed timers, and long-lived DbContext instances accumulating tracked entities.
10. Should you ever call GC.Collect()?
Almost never. It forces a full blocking collection, discards the runtime’s tuning, and promotes surviving objects into more expensive generations, usually making performance worse. Legitimate uses are narrow, such as measuring in a benchmark harness. If memory is growing, the cause is reachability and the tool is a memory profiler.