Most developers treat the garbage collector as weather: it happens to them, and occasionally it ruins their day. But the .NET GC is one of the most configurable mainstream collectors, and the difference between default settings and deliberate ones can be a 40% reduction in p99 latency or a third less memory in containers. The catch is that GC tuning is a system of trade-offs, not a list of magic flags — every knob trades throughput against memory footprint or pause time, and the right setting depends entirely on workload shape.
This post walks through the GC’s actual architecture — generational design, the card table, the large object heap, background collection — and then maps each component to the knobs that control it, with the failure modes that show up in containers. The goal is that when you next see GC Heap in a memory profile, you know which lever actually moves it.
Three generations, and why allocation is nearly free
All reference types live on the managed heap, and allocation from it is a pointer bump: the runtime keeps the address of the next free slot, and newobj advances it. That is the same trick Lisp allocators pulled off decades ago, and it is why the .NET GC can beat malloc on allocation throughput — there is no free list to search. The trick works because objects allocated together die together, which is the empirical observation generational collection exploits.
The heap is split into three generations. New objects land in generation 0; a gen0 collection promotes survivors to gen1; long-lived objects get promoted again to gen2. The design bet is that most objects die young (the weak generational hypothesis), so gen0 collections are both frequent and cheap — they only examine the newest allocations, not the whole heap. Gen2, by contrast, holds everything that survived at least two collections, and collecting it is the expensive case you feel in production.
One consequence trips people up: the generational design is why “it’s garbage collected, memory must free itself” is wrong in an important way. The GC only runs when allocations demand it — a threshold is crossed, or the system is under memory pressure. A service that allocates almost nothing after warmup can hold gigabytes of live objects in gen2 indefinitely without a single full collection. High memory in .NET services is frequently a live-data problem, not a leak, and no amount of tuning changes that.
The card table: how gen0 collections skip the old heap
Here is the mechanism that makes generational collection correct. A gen0 collection must find every reference into gen0 — including references held by old objects in gen1 and gen2. Scanning the entire old generation on every gen0 collection would defeat the point, so the runtime maintains a card table: the heap is divided into fixed-size ranges (cards), and whenever a write instruction stores a reference into an object, the JIT’s write barrier marks that card as dirty. During a gen0 collection, the collector treats all dirty old-generation cards as if they were roots and scans only those cards’ contents, plus the true roots (stacks, registers, statics, GC handles).
The write barrier is a handful of instructions on every reference store — the tax every managed program pays for cheap collection. You never see it in code, but it explains two things worth knowing. First, copying large arrays of references with Array.Copy or Buffer.Memmove is internally optimized to bulk-copy and then dirty the affected cards once, which is why manual per-element copy loops in hot paths are measurably slower. Second, data-oriented designs that group long-lived objects into large arrays allocate less and touch the write barrier less — they are GC-friendly not because of allocation size but because of reference-store count.
For generations 1 and 2, the server builds segments; for gen0 and gen1 (the ephemeral generations), allocation happens in small per-heap budget areas that fill quickly and trigger the cheap collections. When the ephemeral segment fills, gen1 collects and compacts into place. Gen2 is where cost concentrates: its collection compacts the whole old generation, updating references as objects move. Compaction only happens when it recovers meaningful space — if nearly everything survives, the GC skips it rather than paying to move walls of live data.
Server vs workstation: the biggest single decision
The first and most consequential setting is GC flavor. Workstation GC collects on one thread with shorter pauses, optimized for interactive apps. Server GC creates one managed heap per logical core (tunable via GCHeapCount), each with its own collection thread, and collection happens in parallel across heaps. Throughput is dramatically higher; the cost is that each heap has its own ephemeral segment and buffers, so memory usage scales with core count.
// runtimeconfig.json — server GC, capped at 8 heaps on a 32-core box
{
"runtimeOptions": {
"configProperties": {
"System.GC.Server": true,
"System.GC.HeapCount": 8
}
}
}
ASP.NET Core templates enable server GC by default, which is correct for request-heavy services on dedicated hardware. It is a trap in two cases. Containers with high CPU limits: a 32-core node hosting a container limited to 2 cores still creates heaps for the core count the runtime sees, wasting memory on idle heaps. And background services co-located with latency-sensitive workloads: server GC’s parallel collection burns CPU exactly when the process is busy. Both problems have the same fix — either constrain heaps with GCHeapCount/GCHeapAffinitizeMask or drop to workstation GC.
Since .NET 9 the choice is softened by DATAS (Dynamic Adaptation To Application Sizes), enabled by default. DATAS starts with a single heap and scales the heap count up or down based on measured allocation throughput and a throughput-cost target (2% pause time by default), keeping the heap size roughly proportional to long-lived data. It is the middle ground between the two flavors: server-GC throughput under load, workstation-GC footprint at idle. The trade-off shows up as transient latency during load spikes — DATAS needs a few GCs to grow the heap count when a light workload turns heavy, so allocating threads may briefly wait. For steady, predictable load patterns you can still pin the old behavior explicitly with System.GC.DynamicAdaptationMode set to 0 and a fixed heap count.
The LOH: where allocation budgets go to die
Objects at or above 85,000 bytes bypass the generational path entirely and land on the large object heap. Two design choices distinguish it. The LOH is not compacted by default — moving multi-megabyte objects is expensive — so its memory is managed like a native allocator’s: freed ranges go on a free list and get reused, and the address space can fragment over time. And LOH allocations are collected only during gen2 collections, so every large array you allocate during a request is promising to live until a full collection.
The production failure mode is predictable. A service that parses large files or serializes big responses allocates 2 MB byte[] buffers per request; each one survives to gen2; between full collections the LOH grows monotonically; memory charts look like a leak even though everything is reachable-freeable. The fixes, in order of preference: pool large buffers (ArrayPool<T> exists precisely for this), stream instead of materializing (PipeReader/PipeWriter for payloads, streaming serializers over byte[] round-trips), and only then consider GC knobs. GCLOHThreshold can raise the LOH boundary so more mid-size objects stay on the compactable generational heaps — useful when the workload allocates many ~100 KB objects that churn quickly:
{
"runtimeOptions": {
"configProperties": {
"System.GC.LOHThreshold": 120000
}
}
}
Compaction of the LOH is available opt-in per collection (GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce, followed by a forced collection) — a reasonable scheduled-maintenance move for long-running processes with unavoidable large allocations, and a poor substitute for allocating less.
Background GC and what “pause” actually measures
Background collection (on by default, System.GC.Concurrent) runs gen2 collection on dedicated background threads while the application keeps allocating. Gen0 and gen1 collections still stop the world — briefly, typically microseconds to low milliseconds — but full-heap collection overlaps with useful work. Background server GC exists too, so the throughput-oriented flavor no longer forces you to choose between parallel collection and responsiveness.
What remains visible to latency-sensitive services is the ephemeral pause plus the CPU burst. A gen2 background collection can consume a core-equivalent for hundreds of milliseconds on large heaps, which matters on small containers where that CPU is a large fraction of the limit. This is the concrete mechanism behind the common observation that .NET services in tight containers show periodic latency spikes: not one long pause, but collections competing with request handling for a throttled CPU budget. Setting a heap hard limit percent (GCHeapHardLimitPercent) tells the GC to treat the container limit as real and collect more aggressively before approaching it:
{
"runtimeOptions": {
"configProperties": {
"System.GC.HeapHardLimitPercent": 75,
"System.GC.ConserveMemory": 5
}
}
}
A tuning procedure that actually converges
Random flag-flipping produces cargo-cult configs that rot. The sequence that works:
- Measure allocation rate and gen2 cadence first.
dotnet-counters monitor --counters System.Runtimegives you allocation rate, gen0/gen1/gen2 counts, LOH size, and time in GC. The diagnostic problem is almost always visible here before any tuning. - Reduce allocation before tuning collection. The cheapest pause is the collection that never runs. Hot-path string concatenation, LINQ in tight loops, per-request byte arrays, and boxing in logging calls all vanish with a profiler pass, and no GC setting recovers that cost.
- Match flavor to deployment. Dedicated high-throughput host: server GC. Shared or CPU-limited container: workstation, or server with an explicit
GCHeapCount. Unknown: leave .NET 9+ DATAS defaults alone until the counters tell you otherwise. - Constrain memory in containers explicitly. Combine a heap hard limit below the container limit with
System.GC.ConserveMemorywhen footprint matters more than throughput. The GC cannot respect a cgroup limit it does not account for; modern .NET runtimes read container memory limits by default, but an explicit hard limit removes the ambiguity. - Verify against p99, not averages. GC effects concentrate in the tail. Load-test with the final configuration and compare tail latency and memory high-water marks, not mean throughput.
One anti-pattern deserves its own warning: GC.Collect() calls sprinkled through application code. The collector’s optimizing engine already decides when to collect based on allocation behavior, and a forced full collection at the wrong moment costs far more than the memory it momentarily frees. The legitimate uses are narrow — after dropping large cached structures at known-idle moments, or in tests — and they belong in infrastructure code with a comment explaining why, never in request paths.
Wrapping up
The .NET GC is a generational, write-barrier-based collector whose behavior is almost entirely a function of your allocation profile. Server vs workstation, DATAS, the LOH threshold, and heap hard limits are all answers to the same question — how much memory and CPU are you willing to trade for throughput and pause time — and the right answers come from dotnet-counters and tail-latency measurements, not folklore. Reduce allocations first, match the flavor to the deployment, constrain the heap in containers, and let the collector do the rest of its job. Most of the time, the best GC tuning is making less garbage.