Garbage Collection and Memory
The .NET garbage collector (GC) automatically manages the lifetime of objects allocated on the managed heap, freeing developers from manually calling free/delete as in C/C++. Rather than reference counting, the .NET GC is a tracing, generational, mostly compacting collector: it periodically walks from a set of roots (static fields, local variables on the stack, CPU registers) to determine which objects are still reachable, and reclaims the memory of everything else. This design trades some unpredictability in exactly when memory is reclaimed for the elimination of an entire class of bugs — dangling pointers, double frees, and most memory leaks caused by forgetting to deallocate.
Cricket analogy: The GC is like a scorer who periodically walks the ground checking which players are still 'in play' from the pavilion (roots), rather than counting each touch; anyone not reachable gets marked retired, unlike manually declaring 'player out' which risks a double-counted dismissal.
Generations: Gen 0, Gen 1, Gen 2
The GC exploits the empirical observation that most objects die young (the 'generational hypothesis'). New objects are allocated in Gen 0, the smallest and cheapest segment to collect; if an object survives a Gen 0 collection because something still references it, it is promoted to Gen 1, and objects surviving a Gen 1 collection are promoted to Gen 2. Gen 0 and Gen 1 collections are fast and frequent, scanning only a small portion of the heap; Gen 2 collections are more expensive and infrequent, since they must consider the whole surviving object graph. Very large objects (typically ≥ 85,000 bytes) go directly onto the Large Object Heap (LOH), which is collected only during Gen 2 collections and is not compacted by default, making it more susceptible to fragmentation.
Cricket analogy: Most batsmen 'die young' at the crease (Gen 0), so the scorer checks the pavilion gate frequently and cheaply; a rare player surviving many overs gets promoted to a senior watch list (Gen 1, Gen 2); a marquee overseas signing (huge object, LOH) gets its own separate, rarely-audited ledger.
public sealed class ReportBuffer : IDisposable
{
private byte[]? _buffer = new byte[1024 * 1024]; // 1 MB — likely promoted quickly if long-lived
private bool _disposed;
public void Write(ReadOnlySpan<byte> data)
{
ObjectDisposedException.ThrowIf(_disposed, this);
// ... write into _buffer ...
}
public void Dispose()
{
if (_disposed) return;
_buffer = null; // release the managed reference promptly
_disposed = true;
GC.SuppressFinalize(this);
}
}
// Forcing a collection is rarely appropriate outside benchmarking/diagnostics
var before = GC.GetTotalMemory(forceFullCollection: false);
{
using var buffer = new ReportBuffer();
buffer.Write(new byte[100]);
} // Dispose() called deterministically here via 'using'
GC.Collect();
GC.WaitForPendingFinalizers();
var after = GC.GetTotalMemory(forceFullCollection: true);
Console.WriteLine($"Memory delta: {after - before} bytes");Finalizers, IDisposable, and Unmanaged Resources
The GC only knows how to reclaim managed memory; it has no idea how to release unmanaged resources like file handles, OS sockets, or native library handles. A finalizer (declared with a destructor-like ~ClassName() syntax) gives the GC a last chance to clean up unmanaged resources before reclaiming an object, but finalization is nondeterministic (you don't know exactly when it runs), adds objects to a finalization queue that delays their actual reclamation by one extra GC cycle, and runs on a dedicated finalizer thread. The strongly preferred pattern instead is IDisposable combined with a using statement/declaration, which releases resources deterministically at a known point, with a finalizer kept only as a safety net for cases where Dispose was never called.
Cricket analogy: A finalizer is like a backup umpire who eventually notices unreturned equipment days after the match, whereas IDisposable with using is like the twelfth man handing back gear the moment a player leaves the field - deterministic and immediate.
Unlike CPython's primarily reference-counting GC, or manual memory management in C++, .NET's tracing collector correctly reclaims cycles (e.g., two objects referencing each other) without any special handling, because reachability from GC roots — not reference count — determines whether an object survives a collection.
Calling GC.Collect() manually in application code is almost always a mistake: it forces an expensive full collection, defeats the generational optimization (objects that would have died in Gen 0 get needlessly promoted if they survive the forced collection at the wrong moment), and rarely improves real-world throughput or latency outside of specific benchmarking or diagnostic scenarios.
- The .NET GC is a tracing, generational collector that reclaims memory by determining reachability from GC roots, not reference counting.
- Objects are allocated in Gen 0 and promoted to Gen 1/Gen 2 if they survive collections, based on the generational hypothesis that most objects die young.
- Large objects (≥ 85,000 bytes) go on the Large Object Heap, collected only during Gen 2 collections and prone to fragmentation.
- The GC reclaims managed memory only; unmanaged resources require explicit cleanup via IDisposable or finalizers.
- Finalizers provide a nondeterministic safety net for releasing unmanaged resources but add overhead and delay reclamation.
- Manually calling GC.Collect() is rarely appropriate and typically hurts performance by defeating generational optimizations.
Practice what you learned
1. What algorithm does the .NET GC primarily use to determine which objects to reclaim?
2. Why does the generational GC scheme improve performance for typical workloads?
3. What happens to objects at least 85,000 bytes in size?
4. Why is IDisposable with a using statement preferred over relying solely on a finalizer for releasing unmanaged resources?
5. Why is calling GC.Collect() manually in typical application code discouraged?
Was this page helpful?
You May Also Like
using and IDisposable
Learn how .NET manages unmanaged resources through the IDisposable pattern and how the using statement and using declarations guarantee deterministic cleanup.
Exception Handling in C#
C# uses structured try/catch/finally blocks and a typed exception hierarchy to detect, propagate, and recover from runtime errors predictably.
Variables and Data Types
Covers how C# declares and initializes variables, its built-in value and reference types, type inference with var, and the difference between static and dynamic typing.
Common C# Pitfalls
A tour of the mistakes that trip up C# developers most often — from closures over loop variables to silent null reference bugs and misused async void.
Related Reading
Related Study Notes in Programming
Browse all study notesApache Spark Study Notes
Programming · 30 topics
ProgrammingApache Flink Study Notes
Programming · 30 topics
ProgrammingHadoop Study Notes
Programming · 30 topics
ProgrammingSnowflake Study Notes
Programming · 30 topics
ProgrammingApache Airflow Study Notes
Programming · 30 topics
Programmingdbt (Data Build Tool) Study Notes
Programming · 30 topics