dotnet/Runtime & Internals
Garbage collection
intermediateTL;DR
// The mental model: three generations plus a large-object heap.
// gen0 = new, gen1 = survived once, gen2 = long-lived, LOH = objects > 85 KB.
GC.GetGeneration(obj); // where an object lives now
GC.GetTotalMemory(forceFullCollection: false); // managed heap size
GCSettings.IsServerGC; // which flavor you're running
GCSettings.LatencyMode; // e.g. SustainedLowLatency
<!-- csproj switches that actually matter in production -->
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>
How it works
The CLR’s GC is a tracing, mark-and-compact collector: it doesn’t count references (no refcounting, no per-assignment overhead) — it periodically walks the object graph from a set of roots (stack slots in JIT-tracked frames, static fields, and GC handles rooting objects for unmanaged code) and reclaims anything unreachable from them. “Mark-and-compact” means it then plans where survivors should live and slides them together into that space, so the heap stays contiguous and allocation stays a pointer bump, not a free-list walk.
Generations and promotion
The collector is built on the generational hypothesis: most objects die young. Rather than scanning the whole heap on every collection, it buckets objects by age:
- gen0 — freshly allocated objects. Gen0 collections are frequent, fast, and touch only a small, cache-friendly slice of the heap.
- gen1 — objects that survived one gen0 collection; a buffer between short-lived and long-lived.
- gen2 — long-lived objects (caches, singletons, anything referenced from a static). Gen2 collections walk far more of the heap and are correspondingly more expensive.
A collection always starts at gen0; a budget-driven heuristic (allocation volume since the last collection, survival rates, memory pressure) decides whether it stops there or escalates to also collect gen1, then gen2. Any object that survives the collection it’s caught in is promoted up a generation regardless of which generations were collected — promotion isn’t contingent on escalation, only on surviving (gen2 survivors simply stay in gen2 — there’s nowhere higher to promote to).
The large object heap and pinned object heap
Objects larger than 85,000 bytes — large arrays, big strings, boxed
buffers — go straight onto the Large Object Heap instead of gen0. The
LOH is collected as part of a gen2 collection but is, by default, swept
rather than compacted: moving multi-hundred-KB objects on every
collection would be far too expensive, so freed slots become free-list
entries instead. The Pinned Object Heap (since .NET 5) is a separate,
opt-in allocation target: GC.AllocateArray<T>(length, pinned: true) (or
GC.AllocateUninitializedArray<T>) puts the array on the POH from the
start, so it never needs to move and never blocks compaction elsewhere.
Pinning an object in place — via fixed or
GCHandle.Alloc(..., GCHandleType.Pinned) — does not relocate it to
the POH; it stays wherever it was allocated (gen0/1/2 or the LOH) and
blocks compaction of that heap for as long as it’s pinned.
Workstation vs server GC
Workstation GC runs a single heap and is tuned for low-latency, interactive apps. Server GC creates one heap per logical core with a dedicated collecting thread each, trading memory and startup cost for throughput — the default for ASP.NET Core under most hosting models. Either mode can also run background GC: a full gen2 collection happens mostly concurrently with the running application on a dedicated thread, with only short, interleaved gen0/gen1 pauses instead of one long stop.
Finalization and IDisposable
Objects with a finalizer (~Type()) aren’t reclaimed the moment they
become unreachable. The GC instead moves them from the finalization queue
onto an internal freachable queue, runs their finalizer on a dedicated
finalizer thread, and only frees the memory on a subsequent collection —
finalizable objects always survive at least one extra GC compared to an
identical type without a finalizer. That extra hop is exactly why
IDisposable plus a using statement is the deterministic way to release
resources: Dispose() runs synchronously, on your thread, the moment you
say so, instead of waiting on collector timing.
Gotchas
- LOH fragmentation is real. Repeatedly allocating and freeing large,
differently-sized arrays leaves holes in a heap that’s swept, not
compacted — watch it with
GC.GetGCMemoryInfo(), which exposesFragmentedBytesand a per-generationGenerationInfobreakdown (including the LOH), or the equivalent GC event counters, instead of inferring it indirectly. GC.Collect()in production code is almost always a smell. It forces a full, blocking collection and defeats the generational heuristics the collector spent effort tuning; leave it to the runtime except in narrow, measured cases (e.g. right after a huge one-off teardown).- “Mid-life crisis” objects are expensive. Objects that live just long enough to get promoted to gen2, then die shortly after, force gen2 collections — the costly kind — more often than their actual lifetime would suggest.
- Finalizers delay reclamation and run on a single thread. A backlog of
finalizable objects serializes behind one finalizer thread. Implement
IDisposablefor deterministic cleanup and keep a finalizer only as a safety net for callers who forget to dispose. - Pinning in place blocks compaction where the object lives.
fixedandGCHandle.Alloc(..., GCHandleType.Pinned)don’t move an object to the POH — they freeze it in whatever gen0/1/2 or LOH segment it’s already in, forcing the collector to compact around it. Pin briefly and sparingly; for buffers that need to stay pinned for a long time (P/Invoke scratch space, socket I/O), allocate them directly on the POH withGC.AllocateArray<T>(len, pinned: true)instead.
Further reading
- Fundamentals of GC
- Workstation vs server GC
- Maoni Stephens’ blog — the GC team’s own deep dives
- Pro .NET Memory Management (Konrad Kokosa)