The .NET garbage collector is the platform feature most developers are happiest to ignore — until it starts pausing their app for 300 milliseconds at a time. This deep dive covers the model in enough detail to read a profiler trace and make sensible allocation choices, without turning into a textbook.
The mental model: generations
The CLR's GC is generational and compacting. Allocations go onto the small object heap (SOH), which is divided into three generations:
- Gen 0 — young objects. Most die here.
- Gen 1 — survivors of one collection. A buffer zone.
- Gen 2 — long-lived objects (caches, statics, anything that's been around).
Plus the Large Object Heap (LOH) for allocations > 85,000 bytes, which is collected with Gen 2 and (by default) is not compacted.
The key insight: GC cost is proportional to the number of surviving objects scanned, not the total. So Gen 0 collections are cheap (fast death = few survivors), and Gen 2 collections are expensive (everything reachable gets walked).
What happens in a Gen 2 collection
A full GC has roughly four phases:
- Mark. Walk roots (statics, stacks, GC handles), mark every reachable object.
- Plan. Decide what survives and where it'll move.
- Sweep / compact. Free dead memory, move live objects toward the start of each generation.
- Update references. Patch every reference that points to a moved object.
With workstation GC (default for desktop apps), this happens on a single thread. With server GC (default for ASP.NET Core), it runs in parallel on one thread per logical CPU. Both can be either concurrent (most work happens while your code runs) or blocking (everything stops). You'll get blocking GCs under memory pressure regardless of mode.
Allocation patterns that hurt
Three patterns produce most of the GC pain we see in audits:
1. Hidden allocations in hot paths
// Innocent-looking, allocates a string per call.
public string Format(int id, string name) =>
$"id={id};name={name}";
In a hot path, that interpolation produces a string.Format call, a
params object[], and boxing for the int. Use
DefaultInterpolatedStringHandler (the C# 10+ compiler does this for
you in most cases) or write to a pre-existing StringBuilder.
2. Async state machine boxes
Every async method returning Task on an exception or
non-cached completion path allocates a state machine object. For high-frequency
code, return ValueTask and use IValueTaskSource for
pooled completions. Don't do this everywhere — only on measured hot paths.
3. Large arrays on the LOH
Anything >= 85,000 bytes goes to the LOH and stays there until the next Gen 2.
Use ArrayPool<T>.Shared for buffers you can rent and return:
var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(128_000);
try {
ProcessChunk(buffer);
} finally {
pool.Return(buffer, clearArray: true);
}
Reading a trace
The two tools we reach for first:
- dotnet-counters — live counters including GC heap size, Gen 0/1/2 counts, and pause times. Free, cross-platform, runs from the CLI.
- PerfView — captures ETW traces and visualizes them. The "GC Stats" view tells you average pause, % time in GC, and which Gen is triggering most often.
A healthy production trace looks roughly like: many Gen 0s, fewer Gen 1s, very few Gen 2s, and a heap that doesn't grow over time. If Gen 2 collections happen every few seconds, something is being promoted that shouldn't be — usually a cache without bounds, or objects held alive by an event handler.
The new ones: pinned object heap and regions
Two more recent additions worth knowing about:
- POH (Pinned Object Heap) — for objects that need to be pinned
for native interop. Allocate with
GC.AllocateArray<T>(length, pinned: true). Avoids fragmenting the regular heap. - Regions — replaced the older "segments" model in newer .NET versions, giving the runtime smaller, more flexible memory chunks. Mostly transparent; just know the term if you see it in a trace.
Closing thought
The GC is one of those features you get to take for granted 99% of the time and must understand for the other 1%. Spend an afternoon profiling a real service of yours under load — you'll learn more than any blog post can teach.
We run a "performance for .NET developers" track that covers GC tuning, the
Span<T> family, and benchmarking. Get in
touch if you'd like to join.