GC Fundamentals
Key Points
- The .NET GC is a generational, mark-and-sweep, compacting collector. Three generations (0/½) plus the Large Object Heap (LOH) and the Pinned Object Heap (POH).
- Generations exist because most objects die young — gen0 collections are fast and frequent; gen2 collections are slow and rare. Survivors get promoted.
- Workstation GC optimizes for client apps (low pause); Server GC optimizes for throughput on multi-core servers (one heap per logical CPU).
- DATAS (Dynamically Adapting To Application Sizes) became the default for Server GC in .NET 9. It adapts heap sizing dynamically — your container right-sizes itself.
- LOH threshold = 85,000 bytes including object header. LOH is not compacted by default — fragmentation is real and a senior must know how to detect it.
- POH (since .NET 5) is for objects pinned for interop. Use
GC.AllocateArray<T>(length, pinned: true). Keeps pinned objects from fragmenting other heaps. - Background GC runs gen2 concurrently with the application — gen0/gen1 still pause briefly. Foreground gen2 only happens when memory pressure forces it.
Concepts (deep dive)
Why generational?
Empirical observation: most objects die young. (The "weak generational hypothesis.") A method allocates a StringBuilder, returns a string, and the builder goes out of scope before the next GC tick. If the GC scans the whole heap on every collection it pays for surviving objects again and again. Generations let the GC scan only the young part most of the time.
┌─────────────────────────────────────────────────────┐
│ Managed Heap │
├──────────────┬──────────────┬──────────────┬────────┤
│ Gen 0 │ Gen 1 │ Gen 2 │ LOH │ POH
│ (newborns) │ (survived │ (long-lived) │ (≥85KB)│ (pinned)
│ │ one GC) │ │ │
└──────────────┴──────────────┴──────────────┴────────┴─────
Allocations →
small objects ───────────promoted on survive────────►
large objects (≥85KB) → straight to LOH (skipping young generations)
pinned arrays → POH (since .NET 5)
Promotion rules:
- An object that survives a gen0 collection is promoted to gen1.
- An object that survives a gen1 collection is promoted to gen2.
- LOH and POH allocations never travel through gen0/gen1 — they're created in their own segments.
- LOH and POH are collected together with gen2 (you can't gen2-collect without also doing LOH and POH).
💡 Senior insight: "Gen3" doesn't exist as a generation. People sometimes call LOH "gen3" colloquially, but logically LOH is part of gen2 — just stored in a separate segment because of size and compaction rules.
Mark, sweep, compact
A GC has three jobs:
- Mark — walk the object graph from roots (statics, locals on thread stacks, GC handles) and mark everything reachable.
- Sweep — anything not marked is dead.
- Compact — slide live objects together; future allocations bump-allocate into the resulting contiguous free space.
Before GC: [A][B][C][D][E][ ][ ][F][G][ ][H][ ][ ][I][ ][ ] ← fragmented
roots → A, C, E, F, H
Mark: live = {A, C, E, F, H}; B, D, G, I are unreachable
After GC: [A][C][E][F][H][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ][ ] ← compacted
▲
└── allocation pointer; next allocation is bump-pointer fast
This is why .NET allocations are roughly one pointer increment: the bump-allocator works because the heap stays compact. Compaction is the price; the speed of allocation is the prize.
LOH: the exception to compaction
The LOH threshold is 85,000 bytes (chosen long ago to match a free-list allocator break-even). Anything that big skips the young generations and goes straight to LOH.
LOH is swept but not compacted by default — moving large objects is expensive. The price: fragmentation. If you allocate, free, and re-allocate large arrays of varying sizes, you can end up with a heap full of holes that no new allocation fits into, while GC.GetTotalMemory() cheerfully reports plenty of free bytes.
You can force compaction once:
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();
But the better answer is: don't churn the LOH. Use ArrayPool<T> for short-lived large buffers. See Memory Allocations & Pooling.
POH: the pinned object heap
Before .NET 5, pinning an object (fixed, GCHandle.Alloc(obj, GCHandleType.Pinned)) kept it in place — including in gen0 — preventing the GC from compacting around it. Lots of pinned objects = young-gen fragmentation.
POH solves this by giving pinned-by-design allocations their own heap. They don't disturb gen0/gen1.
// .NET 5+
byte[] buffer = GC.AllocateArray<byte>(length: 4096, pinned: true);
// or, for uninitialized memory:
byte[] buffer = GC.AllocateUninitializedArray<byte>(length: 4096, pinned: true);
Use POH for interop buffers, P/Invoke marshaling targets, and Memory<byte> over native I/O. Don't use it for general allocations — POH isn't compacted, so misuse fragments it.
Workstation vs Server GC
| Aspect | Workstation GC | Server GC |
|---|---|---|
| Heaps | One | One per logical CPU |
| GC threads | Background uses the app thread | Dedicated GC thread per heap (high prio) |
| Allocation paths | One | One per CPU — much higher allocation throughput |
| Pause behavior | Optimized for low pause | Optimized for total throughput |
| Default for | Client apps, low core counts | ASP.NET Core, server processes |
Switch via csproj:
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection> <!-- background GC -->
</PropertyGroup>
ASP.NET Core defaults to Server GC + Concurrent (background) GC. Console apps default to Workstation GC.
DATAS — Dynamically Adapting To Application Sizes (.NET 9 default)
In .NET 8, Server GC committed memory aggressively per logical CPU (one heap per CPU, each pre-sized). On a 32-core container with 1 GB of RAM allocated, that was a problem.
DATAS, default in .NET 9 for Server GC, starts with one heap and adds heaps dynamically based on heap-size growth and a Throughput Cost Percentage (TCP) target (default 2%). Your container's GC right-sizes itself to actual workload, not to logical CPU count.
💡 Senior insight: DATAS is the single most impactful GC change in years for containerized .NET. If you've been pinning
DOTNET_GCHeapCountto manage memory in containers, DATAS likely lets you remove that.
Background GC (concurrent gen2)
Gen0/gen1 collections are always stop-the-world — but they're tiny (microseconds to low milliseconds). Gen2 used to also be stop-the-world, which meant unpredictable pauses on large heaps.
Background GC (default since .NET 4) runs gen2 concurrently with the application. Marking traverses the heap in parallel with mutation; a brief stop-the-world phase finishes the cycle. Pauses drop dramatically for gen2.
Time →
App thread: [run]....[run].......[run].........[run].................[run]
│ │ │ │
▼ ▼ ▼ ▼
GC thread: Gen0 (STW, ~100µs)
Gen2 background marking (concurrent)
Gen0 (STW)
Gen2 STW finalize
(~1-5 ms)
⚠️ Gotcha: if allocation pressure during background marking exceeds what background GC can keep up with, the runtime falls back to a foreground gen2 — full stop-the-world. Common cause: very high allocation rate plus low CPU available to GC threads.
GC modes / latency control
GCSettings.LatencyMode adjusts behavior:
| Mode | Effect |
|---|---|
Interactive (default) | Background gen2; balanced. |
Batch | No concurrency; throughput-biased. Use for batch jobs. |
LowLatency | Suppresses gen2 collections (workstation only, short windows). Risk: OOM if held too long. |
SustainedLowLatency | Background gen2 only; foreground gen2 only on memory pressure. Use for trading platforms, real-time pipelines. |
NoGCRegion | GC.TryStartNoGCRegion(size) — no GC for the next N bytes of allocation. Hard limits; throws if exceeded. |
⚠️ Don't reach for these unless you're measuring. Default
Interactiveis right for ~99% of workloads.
Write barriers and card tables
When gen2 holds a reference to a gen0 object (a cross-generational reference), the GC must know about it to find that gen0 object during a young collection — without scanning all of gen2. The mechanism: a write barrier intercepts every reference store, marking a card in a bit-array (the card table) corresponding to the page being mutated. When gen0 collects, the GC scans only the cards marked dirty since the last collection.
This is why class C { object _x; } — every assignment to _x runs through a write barrier. Value-type fields don't need it (they're updated in place, not as references). It's also why struct allocations on the stack avoid write-barrier costs entirely.
How it works under the hood
The GC is implemented in src/coreclr/gc/ in dotnet/runtime. Key entry points:
- Allocation fast path — typically one pointer-bump in the per-thread allocation context. Inlined into JIT-generated allocations.
- Allocation slow path — bump pointer past end of context; refresh context; if heap full, trigger gen0 collection.
GarbageCollect()— the orchestrator. Decides which generation to collect based on budget and allocation thresholds.mark_phase()— graph traversal from roots.plan_phase()— figure out the new layout.relocate_phase()— fix up references.compact_phase()— actually move the bytes.
Roots come from:
- Static fields of loaded types.
- Thread stacks (locals and arguments).
- GC handles (
GCHandle.Alloc, used heavily in interop and frameworks). - The finalization queue (objects waiting for finalization are roots until finalizer runs).
Reference types share a method table pointer (the first 8 bytes on x64, or 4 on x86) plus a sync block index. Allocations are essentially: new_obj_ptr = alloc_ptr; alloc_ptr += size; plus initializing the method-table pointer. Sub-microsecond on the fast path.
Code: correct vs wrong
❌ Wrong: forcing collections "just in case"
public void ProcessBatch(IEnumerable<Item> items)
{
foreach (var item in items)
Process(item);
GC.Collect(); // ❌ wrong — induces full GC, often makes things worse
GC.WaitForPendingFinalizers();
GC.Collect();
}
Calling GC.Collect() forces gen2 + LOH + POH; freezes the world; trashes the GC's adaptive heuristics; and undoes the runtime's careful pacing. It's almost always a bug masking a real allocation problem.
✅ Correct: profile and reduce allocations instead
public void ProcessBatch(IEnumerable<Item> items)
{
foreach (var item in items)
Process(item);
// GC handles itself. If you're seeing GC pressure, profile with dotnet-counters
// and address the actual allocation hot path. See gc-tuning-and-diagnosing.md
}
❌ Wrong: holding onto a GCHandle that's pinning gen0 objects
var array = new byte[8192];
var handle = GCHandle.Alloc(array, GCHandleType.Pinned); // ❌ pins this in gen0
// ... long-running operation ...
// gen0 fragments around the pinned object
✅ Correct: allocate on the POH
var array = GC.AllocateArray<byte>(length: 8192, pinned: true);
// goes to POH; doesn't fragment gen0
fixed (byte* p = array)
{
NativeApi.Use(p);
}
❌ Wrong: large allocations in a tight loop
for (int i = 0; i < 1_000_000; i++)
{
var buffer = new byte[100_000]; // ❌ LOH every time; LOH fragments
Process(buffer);
}
✅ Correct: pool large buffers
var pool = ArrayPool<byte>.Shared;
for (int i = 0; i < 1_000_000; i++)
{
var buffer = pool.Rent(100_000);
try { Process(buffer); }
finally { pool.Return(buffer); }
}
Design patterns for this topic
Pattern 1 — "Allocate on the path you measure"
- Intent: make allocation decisions empirical, not theoretical.
- When to use it: before any optimization for GC pressure.
- Code sketch:
[MemoryDiagnoser] // BenchmarkDotNet attribute
public class HotPathBench
{
[Benchmark] public void Naive() { /* ... */ }
[Benchmark] public void Pooled() { /* ... */ }
}
- Trade-offs: the only way to know is to measure. Premature optimization is the larger risk.
Pattern 2 — "Stack-allocate when small, heap-allocate when large"
- Intent: avoid heap allocations for short-lived buffers under a known size cap.
- When to use it: parsing, formatting, fixed-size scratch buffers.
- Code sketch:
const int Threshold = 1024;
Span<byte> buffer = size <= Threshold
? stackalloc byte[size]
: new byte[size];
Process(buffer);
- Trade-offs:
stackallocin a loop accumulates on the stack (not freed until the method returns). Don't do it.
Pattern 3 — "POH for interop, ArrayPool for everything else"
- Intent: match the allocation strategy to the lifetime.
- When to use POH: buffers passed to native APIs that pin them.
- When to use ArrayPool: large, short-lived managed buffers.
- Code sketch: see "correct vs wrong" above.
Pattern 4 — "Avoid the LOH threshold"
- Intent: if you're allocating arrays close to 85 KB, slide either down (use multiple smaller arrays + Span composition) or up (rent from
ArrayPoolso the same buffer is reused). - When to use it: any code path with array sizes 50 KB–200 KB allocated in a loop.
Pattern 5 — "GC.TryStartNoGCRegion for hot critical sections"
- Intent: guarantee no GC during a latency-critical operation.
- When to use it: trading platforms, real-time message processing where a GC tick is unacceptable.
- Code sketch:
if (GC.TryStartNoGCRegion(totalSize: 8 * 1024 * 1024))
{
try { ProcessLatencyCritical(); }
finally { GC.EndNoGCRegion(); }
}
else
{
// GC could not commit memory; fall back
ProcessLatencyCritical();
}
- Trade-offs: narrow tool. If your "no-GC region" allocates more than the budget, you get a
InvalidOperationExceptionon exit. Reserve for measured, instrumented use.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Generational GC | Cheap young collections; matches typical app behavior | Cross-generation references add write-barrier cost |
| Server GC | High throughput on many-core hosts | Higher base memory footprint (mitigated by DATAS) |
| Background GC | Minimizes gen2 pauses | Can fall back to foreground under pressure |
| LOH | Avoids compaction cost | Fragments under churn |
| POH | Pinning without fragmentation | Not compacted; misuse fragments |
| Workstation GC | Lower pause variance | Lower throughput on many-core boxes |
When to use / when to avoid
- Use Server GC + Background GC for ASP.NET Core, gRPC services, anything multi-core throughput-bound. Default in modern templates.
- Use Workstation GC for client apps, low-core devices, small CLIs.
- Use POH only for buffers genuinely needing native pinning.
- Avoid
GC.Collect()— almost always a bug. - Avoid pinning gen0 objects via
GCHandle— use POH instead. - Avoid the 85 KB cliff — either stay clearly below it or pool clearly above it.
Interview Q&A
Q1. What are the .NET GC generations and why does the design exist? Three generations (0, 1, 2) plus the LOH and POH. Generations exist because most objects die young — the "weak generational hypothesis." Gen0 collections are fast and frequent; gen2 collections are slow and rare. Survivors get promoted: object survives a gen0 → goes to gen1; survives gen1 → gen2.
Q2. What's the LOH threshold and why does it exist? 85,000 bytes (including object header). Objects this size or larger go straight to LOH. The threshold dates back to a tradeoff between free-list allocators and the compaction cost of moving large objects. LOH is swept but not compacted by default, which means it can fragment.
Q3. How does Server GC differ from Workstation GC? Server GC creates one heap per logical CPU with dedicated GC threads, optimizing for throughput. Workstation GC is a single heap, optimizing for low pause. ASP.NET Core defaults to Server GC + Background GC.
Q4. What changed with DATAS in .NET 9? DATAS (Dynamically Adapting To Application Sizes) became the default for Server GC. Instead of allocating one heap per logical CPU at startup, it starts with one heap and grows based on observed workload, targeting a Throughput Cost Percentage (default 2%). Huge win for containerized workloads.
Q5. What is the POH and when do you use it? The Pinned Object Heap. Allocate via GC.AllocateArray<T>(length, pinned: true). Use for buffers that must be pinned for native interop. Avoids fragmenting gen0 with pinned objects.
Q6. Explain background GC and the foreground fallback. Background GC runs gen2 concurrently with the application — only brief stop-the-world phases for sweep finalization. If allocation pressure during background marking outpaces what the GC can sustain, the runtime falls back to foreground gen2 (full stop-the-world). Common cause: high allocation rate + low CPU available to GC threads.
Q7. Why is GC.Collect() almost always a bug? It forces a full collection (gen2 + LOH + POH), freezes the world, and undoes the GC's adaptive heuristics. Real GC pressure problems are caused by allocation patterns; the right tool is profiling (dotnet-counters / dotnet-trace), not forcing.
Q8. What's a write barrier and why does it exist? A small piece of code the JIT inserts on every reference field write, marking a "card" in a card table. Lets the GC find cross-generational references (gen2 → gen0) during a gen0 collection without scanning all of gen2. Value-type fields don't need it, which is one reason struct updates can be cheaper than class updates.
Q9. You see your gen2 working set growing over time. What do you check? 1) Allocation profile with dotnet-counters (gen-2-gc-count, gen-2-size). 2) GC trace with dotnet-trace collecting the GC provider. 3) Look for unintended object retention — event handler subscriptions, static caches without bounds, captured closures. 4) Check finalizer queue — finalizable objects are gen2-resurrected. 5) Heap dump with dotnet-gcdump and analyze in PerfView/Visual Studio.
Q10. Walk through what happens when you allocate var s = "hello".Substring(0, 3). 1) JIT-generated allocation slot grabs space from the per-thread allocation context. 2) Object header initialized (method table pointer, sync block index). 3) string constructor copies bytes. 4) Allocation pointer bumped past the new object. Total: roughly two pointer increments and a bytes copy.
Q11. When does the LOH compact? By default, never. You can request compaction once via GCSettings.LargeObjectHeapCompactionMode = CompactOnce followed by GC.Collect(). Containers (since .NET Core 3.0) can auto-trigger LOH compaction when memory pressure is high.
Q12. What is the difference between GC.AllocateArray<T>(n, pinned: true) and GCHandle.Alloc(arr, GCHandleType.Pinned)? The first allocates the array directly on the POH — never disturbs gen0/gen1. The second pins an existing object wherever it lives — if the array is in gen0, it gets stuck there and fragments the heap around it. Modern code prefers POH.
Q13. What's SustainedLowLatency mode and when would you use it? A GCSettings.LatencyMode that biases the GC toward background gen2 only — no foreground gen2 unless memory pressure forces it. Use for steady-state low-latency systems (trading, real-time data pipelines) running for short windows. Don't leave it on permanently.
Q14. How does the GC find roots on the thread stack? At each GC, threads are suspended at safe points. The JIT publishes per-method GC info describing which stack offsets and registers hold managed references at each instruction. The GC walks each thread's stack consulting GC info for each frame. This is why "you can't await inside a lock" historically — the awaiter could move work to another thread, and the GC info accounting around lock release got complicated.
Gotchas / common mistakes
- ⚠️ Calling
GC.Collect()in production code — almost always a bug. Profile and fix the cause. - ⚠️ Pinning gen0 objects with
GCHandle— fragments the young heap. Use POH. - ⚠️ Allocating just over 85,000 bytes in a loop — LOH churn. Pool, or stay below.
- ⚠️ Static fields holding caches without bounds — gen2 retention. Use
ConditionalWeakTable, weak references, or LRU caches. - ⚠️ Subscribing event handlers without unsubscribing — short-lived handler captures long-lived publisher; everything in handler closure is gen2-rooted.
- ⚠️ Finalizers that never get processed — finalizable objects are gen2-resurrected once; if the finalizer queue backs up under load, gen2 grows. Use
IDisposable, suppress finalize. - ⚠️ Assuming GC.GetTotalMemory tells you the truth — it doesn't include all heaps and depends on the
forceFullCollectionparameter. UseGC.GetGCMemoryInfo()for the real picture. - ⚠️ Forgetting that
awaitflows ExecutionContext —AsyncLocal<T>values are retained across awaits, so a "leaky" AsyncLocal can pin large object graphs.
Further reading
- GC Fundamentals (MS Learn)
- Large Object Heap
- DATAS
- Latency modes
- GC source — dotnet/runtime
- Maoni Stephens (lead GC dev) — Maoni's blog