Garbage Collection in C# and Automatic Memory Management in the CLR
Every object you create with new takes some memory. In languages like C and C++, the programmer must remember to free that memory later. Forget it, and the program slowly eats all the RAM (a memory leak). Free it twice or too early, and the program crashes.
C# takes this job away from you. The Common Language Runtime (CLR) has a built-in Garbage Collector (GC) that automatically finds objects your program no longer uses and frees their memory. This is called automatic memory management.
Think of a food court. You take a tray, eat, and simply leave the tray on the table. You don't wash it yourself. A cleaner walks around from time to time, collects trays that nobody is using any more, and makes the tables available again. You never have to think about it, as long as you don't keep holding a tray you have finished with. The GC is that cleaner.
In this lesson you will learn how the CLR manages memory, how the garbage collector decides what to clean, what generations are, and how you can control memory explicitly in C# code using GC methods, IDisposable, using, finalizers and weak references. Every output shown below comes from a real run on .NET 8.
Stack and heap: where data lives
The CLR uses two main memory areas:
| Stack | Managed heap | |
|---|---|---|
| Stores | Local variables and method parameters of value types, and the references (addresses) of objects | All objects created with new (class instances, arrays, strings, boxed values) |
| Freed | Automatically and instantly when the method returns | By the garbage collector, when objects are no longer reachable |
| Speed | Very fast | Fast to allocate; cleaning has a cost |
void PlaceOrder()
{
int quantity = 3; // value type: lives on the stack
Order order = new Order(101); // the Order OBJECT lives on the heap,
// the variable 'order' (a reference) is on the stack
} // method ends: 'quantity' and the reference 'order' are gone at once.
// The Order object is now unreachable garbage, waiting for the GC.
The stack cleans itself. The heap is where the garbage collector works.
How the CLR allocates memory
When the program starts, the CLR reserves a region of memory called the managed heap. It keeps a pointer to the next free address. Every new simply:
- places the object at the next free address,
- moves the pointer forward by the size of the object.
That's why allocating objects in .NET is extremely fast: it's almost as cheap as moving a pointer. The cost comes later, when the heap fills up and the GC has to clean it.
How the garbage collector works
The GC does not run all the time. It runs when it needs to, mainly when:
- the memory set aside for new objects (generation 0, see below) is full,
- the operating system reports that the machine is low on memory,
- your code calls
GC.Collect()(rarely a good idea; see below).
A collection has three phases:
- Mark: the GC starts from the roots, the places your program can reach objects from directly: local variables and parameters of running methods,
staticfields, and a few internal references. It follows every reference from those objects to other objects, and marks everything it can reach as alive. - Sweep: every object that was not marked is unreachable. Nobody can ever use it again, so its memory can be reused.
- Compact: the surviving objects are moved together to remove the gaps, and all references to them are updated. The free space is now one continuous block again, so allocation stays fast.
Roots (locals, statics)
│
├──► Customer ──► Address alive (reachable)
│
└──► Order ──► OrderItem alive (reachable)
Report ──► Chart garbage (nothing reaches them)
TempList garbage
Before compaction: [Customer][Report][Address][TempList][Order][Chart][OrderItem]
After compaction : [Customer][Address][Order][OrderItem][ free space ]
An object is garbage when nothing reachable points to it. It does not matter whether you set a variable to null; what matters is whether any live code can still get to the object.
Generations: the GC's smart shortcut
Checking the whole heap every time would be slow. The .NET GC uses a simple observation from real programs: most objects die young. A temporary string, a DTO for one request, a LINQ iterator: these are used for a moment and then thrown away. Objects that survive a while (caches, configuration, singletons) usually live for a long time.
So the heap is divided into three generations:
| Generation | Contains | Collected |
|---|---|---|
| Gen 0 | Newly created objects | Very often, and very fast (it's small) |
| Gen 1 | Objects that survived one gen 0 collection: a buffer between short and long-lived objects | Less often |
| Gen 2 | Long-lived objects (survived gen 1), and large objects | Rarely; a gen 2 collection (a "full GC") is the most expensive |
When an object survives a collection, it is promoted to the next generation. You can watch this with GC.GetGeneration():
var order = new Order(1);
Console.WriteLine($"New object : generation {GC.GetGeneration(order)}");
GC.Collect();
Console.WriteLine($"After 1st collection : generation {GC.GetGeneration(order)}");
GC.Collect();
Console.WriteLine($"After 2nd collection : generation {GC.GetGeneration(order)}");
GC.Collect();
Console.WriteLine($"After 3rd collection : generation {GC.GetGeneration(order)}");
byte[] big = new byte[100_000];
Console.WriteLine($"100,000-byte array : generation {GC.GetGeneration(big)} (Large Object Heap)");
GC.KeepAlive(order); // keeps 'order' reachable until here (explained later)
- Each collection the object survives moves it one generation up, until it reaches gen 2 (the highest,
GC.MaxGeneration). - The large array went straight to gen 2. Objects of 85,000 bytes or more are placed on the Large Object Heap (LOH), which is collected together with gen 2. Moving big objects is expensive, so by default the LOH is not compacted.
Short-lived objects are cheap
This loop creates 5 million Order objects, each one garbage immediately after use:
int g0 = GC.CollectionCount(0), g1 = GC.CollectionCount(1), g2 = GC.CollectionCount(2);
long total = 0;
for (int i = 0; i < 5_000_000; i++)
{
var temp = new Order(i); // garbage right after this line
total += temp.Id;
}
Console.WriteLine($"Gen 0 collections: {GC.CollectionCount(0) - g0}");
Console.WriteLine($"Gen 1 collections: {GC.CollectionCount(1) - g1}");
Console.WriteLine($"Gen 2 collections: {GC.CollectionCount(2) - g2}");
Five million objects were cleaned with only 19 quick gen 0 collections and no expensive gen 2 collection. Because every object was already dead, each collection had almost nothing to keep. This is exactly the pattern the generational GC is designed for.
Managed and unmanaged resources
The GC is excellent at one thing: freeing managed memory. But programs also use resources the GC knows nothing about. These are unmanaged resources:
- open files (file handles),
- database connections,
- network sockets, HTTP connections,
- operating-system handles, GDI objects (brushes, bitmaps), memory allocated by native code.
The GC frees the small .NET object that wraps such a resource, but only when it decides to run, which could be seconds or minutes later. Meanwhile the file stays locked and the database connection stays taken from the pool. If you open connections in a loop and wait for the GC to clean them up, the connection pool runs out and the application stops working.
Rule: memory is cleaned automatically by the GC. Resources such as files and connections must be released explicitly, as soon as you are done with them. C# gives you IDisposable and using for this.
Explicit memory management in C#
"Automatic" doesn't mean "no control". C# gives you several tools to work with the GC and release resources at the right time:
| Tool | Purpose | How often you'll use it |
|---|---|---|
IDisposable + using | Release files, connections and other resources immediately | All the time |
Finalizer ~ClassName() | Safety net that releases an unmanaged resource if Dispose was forgotten | Rarely (prefer SafeHandle) |
GC.SuppressFinalize | Tell the GC the finalizer is not needed because Dispose already ran | In the dispose pattern |
GC.Collect, GC.WaitForPendingFinalizers | Force a collection | Almost never in production; useful in tests and demos |
GC.GetGeneration, GC.CollectionCount, GC.GetTotalMemory | Inspect the GC and memory use | Diagnostics |
WeakReference | Refer to an object without keeping it alive | Caches and special cases |
GC.KeepAlive | Keep an object reachable until a certain point | Interop with native code |
1. IDisposable and using: the most important tool
A class that holds a resource implements IDisposable, whose single method Dispose() releases it. The using statement calls Dispose() automatically when the block ends, even if an exception is thrown.
Here is a small class that simulates a report file. It prints a message when it opens and when it is cleaned up, so we can see exactly when each step happens:
class ReportFile : IDisposable
{
private readonly string _name;
private bool _disposed;
public ReportFile(string name)
{
_name = name;
Console.WriteLine($"Opening {_name}"); // e.g. open a file handle
}
public void Write(string text) => Console.WriteLine($"Writing to {_name}: {text}");
// Called by your code (or by 'using')
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this); // cleanup is done: the finalizer is not needed any more
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return; // safe to call Dispose more than once
if (disposing)
Console.WriteLine($"Dispose(): closing {_name}");
else
Console.WriteLine($"Finalizer: cleaning up {_name} (Dispose was never called!)");
_disposed = true;
}
// Finalizer: called by the GC only if Dispose() was NOT called
~ReportFile() => Dispose(disposing: false);
}
Using it with a using block:
using (var file = new ReportFile("sales.txt"))
{
file.Write("Total sales: 50,000");
}
Console.WriteLine("After the using block");
The resource was released exactly at the closing brace, not "whenever the GC runs". The compiler turns the using block into a try/finally:
var file = new ReportFile("sales.txt");
try
{
file.Write("Total sales: 50,000");
}
finally
{
file?.Dispose(); // always runs, even after an exception
}
Since C# 8 you can also write a using declaration without braces. The object is disposed at the end of the enclosing block or method:
public async Task<List<string>> GetCustomerNamesAsync()
{
using var connection = new SqlConnection(_connectionString); // disposed when the method ends
await connection.OpenAsync();
using var command = new SqlCommand("SELECT Name FROM Customers", connection);
using var reader = await command.ExecuteReaderAsync();
var names = new List<string>();
while (await reader.ReadAsync())
names.Add(reader.GetString(0));
return names;
} // reader, command and connection are disposed here, in reverse order
For resources that need asynchronous cleanup, implement IAsyncDisposable and use await using.
2. Finalizers: the safety net
A finalizer (written ~ClassName(), also called a destructor) is a method the GC calls before it frees an object. It exists only as a safety net for unmanaged resources in case someone forgets to call Dispose().
Let's forget to dispose on purpose:
static void CreateAndForget()
{
var report = new ReportFile("temp.txt"); // no using, no Dispose()
}
CreateAndForget();
Console.WriteLine("Calling GC.Collect()...");
GC.Collect(); // the object is unreachable: GC finds it
GC.WaitForPendingFinalizers(); // wait until its finalizer has run
Console.WriteLine("Finalizers finished");
The resource was cleaned up, but only because we forced a collection. In a real application, nobody knows when the GC will run, so the file could stay open for a long time. That's why finalizers are a last resort, not a replacement for Dispose.
Finalizers also have a cost:
- An object with a finalizer survives at least one extra collection: the GC first puts it in a queue, a separate finalizer thread runs the finalizer, and only the next collection frees the memory. It is often promoted to an older generation along the way.
- You can't know when, or in which order, finalizers run.
- A finalizer must not use other managed objects, which may already have been finalized.
Most classes should not have a finalizer. If your class only holds other IDisposable objects (a SqlConnection, a FileStream), just implement Dispose() and call their Dispose(); they already have their own safety nets. For raw operating-system handles, wrap them in a SafeHandle instead of writing a finalizer yourself.
3. The dispose pattern and GC.SuppressFinalize
The ReportFile class above follows the standard dispose pattern:
Dispose()callsDispose(true)and thenGC.SuppressFinalize(this).- The finalizer calls
Dispose(false). Dispose(bool disposing)does the real work. Whendisposingistrue, it may also dispose other managed objects; when it isfalse(called from the finalizer), it must release only unmanaged resources.- A
_disposedflag makes it safe to callDisposemore than once.
GC.SuppressFinalize(this) tells the GC: "cleanup has already happened, don't run my finalizer". The object can then be freed in a normal, cheap collection:
static void DisposeProperly()
{
using var report = new ReportFile("audit.txt");
}
DisposeProperly();
GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine("No finalizer message above: SuppressFinalize worked");
4. GC.Collect: forcing a collection
You can ask the GC to run immediately:
GC.Collect(); // collect all generations
GC.Collect(0); // collect only generation 0
GC.WaitForPendingFinalizers(); // wait for finalizers to finish
GC.Collect(); // collect objects whose finalizers just ran
And measure memory before and after. Here a method allocates 100 arrays of 10,000 bytes (about 1 MB):
Console.WriteLine($"Before allocating : {GC.GetTotalMemory(true):N0} bytes");
var list = AllocateList(); // 100 x new byte[10_000]
Console.WriteLine($"After allocating : {GC.GetTotalMemory(false):N0} bytes");
list = null;
Console.WriteLine($"After collecting : {GC.GetTotalMemory(true):N0} bytes");
GC.GetTotalMemory(true) forces a collection before measuring; false just reads the current number. After the list became unreachable, about 1 MB was freed.
Don't call GC.Collect() in normal application code. The GC tunes itself based on how your program allocates memory. Forcing collections usually makes performance worse: it promotes live objects to older generations, interrupts all threads, and throws away the GC's own tuning. Reasonable uses are unit tests, benchmarks, demos like this one, or rare moments where you just released a huge amount of memory and the app is about to be idle.
5. WeakReference: refer to an object without keeping it alive
A normal (strong) reference keeps an object alive. A WeakReference lets you point to an object while still allowing the GC to collect it. That's useful for caches of data that can be rebuilt if needed:
static WeakReference MakeWeak() => new WeakReference(new byte[1000]);
var weak = MakeWeak();
Console.WriteLine($"Before GC: IsAlive = {weak.IsAlive}");
GC.Collect();
Console.WriteLine($"After GC : IsAlive = {weak.IsAlive}");
Only the weak reference pointed to the array, so the GC was free to collect it. Use the generic WeakReference<T> with TryGetTarget(out T target) in real code. For most caching needs, though, MemoryCache with an expiration time is the better choice.
6. GC.KeepAlive
In an optimized (Release) build, the JIT compiler knows when a local variable is used for the last time, and the object can become garbage before the method ends. Usually that's good. But if native code is still using the object (for example, you passed its handle to a Windows API), it must stay alive until that code finishes. GC.KeepAlive(obj) marks the point until which the object must stay reachable. In the generations demo above, it kept order alive until the end, so the GC didn't free it while we were watching its generation.
7. Telling the GC about native memory
If a small .NET object holds a large block of native memory, the GC only sees the small object and may not feel any reason to collect. GC.AddMemoryPressure(bytes) tells it about the hidden native memory, and GC.RemoveMemoryPressure(bytes) must be called when that memory is freed.
GC modes: workstation and server
The CLR has two main GC "flavours":
| Workstation GC | Server GC | |
|---|---|---|
| Designed for | Desktop and console apps; keeps the app responsive | Web servers and services with high throughput |
| Heaps | One heap | One heap (and GC thread) per CPU core |
| Default for | Console, WinForms, WPF apps | ASP.NET Core apps |
Both use background GC by default: gen 2 collections mostly run on a separate thread while your code keeps running. You can check the current settings in code:
using System.Runtime;
Console.WriteLine($"Server GC : {GCSettings.IsServerGC}");
Console.WriteLine($"Latency mode : {GCSettings.LatencyMode}");
Console.WriteLine($"Max generation: {GC.MaxGeneration}");
You can change the mode in the project file, for example <ServerGarbageCollection>true</ServerGarbageCollection> or <ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>. For most applications, the defaults are the right choice.
Memory leaks still happen in .NET
The GC frees objects that are unreachable. If your code accidentally keeps a reference, the object stays alive forever. That is a managed memory leak. Common causes:
- Static collections that only grow, such as a
static List<Order>used as a "cache" that is never cleared. - Event handlers that are never removed. If a long-lived object (a static event, a singleton service) holds an event that a short-lived object subscribed to with
+=, the publisher keeps the subscriber alive. Unsubscribe with-=when done. - Wrong DI lifetimes in ASP.NET Core, such as a singleton service holding scoped objects or per-request data.
- Undisposed objects that register timers or callbacks (
Timer,CancellationTokenSourceregistrations).
Tools such as the Visual Studio Diagnostic Tools window, dotnet-counters (live GC statistics) and dotnet-gcdump (what is on the heap) help you find them.
Best practices
- Let the GC manage memory; don't call
GC.Collect()in production code. - Always dispose objects that implement
IDisposable, preferably withusingorusing var. - Implement
IDisposablein your own classes when they hold disposable objects or unmanaged resources. - Avoid finalizers unless you directly own an unmanaged resource; prefer
SafeHandle. If you do write one, callGC.SuppressFinalize(this)inDispose(). - Short-lived objects are cheap; long-lived and large objects (85,000 bytes or more) are the expensive ones. Reuse big buffers (for example with
ArrayPool<T>) in hot paths. - Unsubscribe from events and don't let static collections grow forever.
Interview questions
1. What is garbage collection in C#?
It is the CLR's automatic memory management: the garbage collector finds objects on the managed heap that are no longer reachable from the application's roots and reclaims their memory, so developers don't free memory manually.
2. What are generations in .NET GC?
The managed heap is divided into generation 0 (new objects, collected often), generation 1 (a buffer for objects that survived once) and generation 2 (long-lived and large objects, collected rarely). Most objects die young, so collecting gen 0 often and gen 2 rarely is efficient.
3. What is the Large Object Heap?
Objects of 85,000 bytes or more are allocated on the Large Object Heap, which is collected together with generation 2 and, by default, not compacted, because moving large objects is expensive.
4. What is the difference between Dispose and Finalize?
Dispose() is called by your code (or a using block) to release resources immediately and deterministically. A finalizer is called by the GC at an unknown time, before the object's memory is freed, as a safety net if Dispose() was not called. Objects with finalizers also cost extra GC work.
5. Why do we call GC.SuppressFinalize(this) in Dispose?
Because the resources are already released, so the finalizer is no longer needed. Suppressing it lets the GC free the object in a normal collection, without the extra finalization step.
6. Should we call GC.Collect()?
Generally no. The GC is self-tuning, and forcing collections usually hurts performance by promoting objects and interrupting threads. It's acceptable in tests, benchmarks, or rare, well-measured cases.
7. Can a .NET application have memory leaks?
Yes. If objects stay reachable by mistake, through static collections, un-removed event handlers, long-lived caches or wrong DI lifetimes, the GC cannot free them. Resource leaks also happen when IDisposable objects are not disposed.
8. What happens behind a using statement?
The compiler turns it into a try/finally block that calls Dispose() in the finally, so the resource is released even when an exception is thrown.
Summary
- The CLR allocates objects on the managed heap and the garbage collector frees unreachable objects automatically.
- A collection marks reachable objects from the roots, sweeps the rest, and compacts the heap.
- Generations 0, 1 and 2 make collection fast; objects of 85,000+ bytes go to the Large Object Heap (gen 2).
- The GC frees memory, not resources: release files and connections with
IDisposableandusing. - Finalizers are a slow safety net; the dispose pattern with
GC.SuppressFinalizecombines both correctly. - Use
GCmethods for diagnostics and tests,WeakReferencefor rebuildable data, and avoidGC.Collect()in production.
Related: Common Language Runtime and Dispose vs Finalize.