~/handys11
Browse the docs

dotnet/Runtime & Internals

Garbage collection

intermediate

TL;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 exposes FragmentedBytes and a per-generation GenerationInfo breakdown (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 IDisposable for 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. fixed and GCHandle.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 with GC.AllocateArray<T>(len, pinned: true) instead.

Further reading