Full GCs, triggers (budget, LOH allocation, induced, low memory), blocking versus background, compaction, and a method to diagnose frequent Gen 2 collections.
A Gen 2 GC collects the whole managed heap: Gen 0, Gen 1, Gen 2, the LOH and the POH. It is triggered when Gen 2's budget is exceeded by promotions, when large object allocations exceed the LOH budget, under memory pressure (approaching the container or GCHeapHardLimit), or by code calling GC.Collect(). Most Gen 2 GCs run as background GCs that let threads keep running; blocking full GCs (with compaction) pause everything. To investigate frequent Gen 2 GCs, check the GC reason (dotnet-trace or PerfView GCStats), then the cause: LOH churn, mid-life objects, induced collections, or memory limit pressure.
"A Gen 2 or 'full' GC collects the entire managed heap: Gen 0, Gen 1, Gen 2, the LOH and the POH. It happens when promotions exhaust Gen 2's budget, when large allocations exhaust the LOH budget, under memory pressure near the container or hard limit, or when someone calls
GC.Collect(). Most Gen 2 GCs run in the background while the app keeps going; the ones that hurt are blocking full GCs, which can compact and pause for a long time on large heaps. When Gen 2 GCs are frequent, I don't guess: I capture GC events, read the reason and type of each GC, and follow the reason to the cause."
| Part of heap | Collected in a Gen 2 GC? | Compacted? |
|---|---|---|
| Gen 0, Gen 1 | Yes | Usually |
| Gen 2 (SOH) | Yes | Only in a blocking compacting GC |
| LOH | Yes | Only if requested (CompactOnce) or under hard-limit pressure |
| POH | Yes | Never |
Marking cost scales with live objects and references across the whole heap, which is why a full GC is far more expensive than a young one.
| GC reason (in traces) | What triggered it | First suspect |
|---|---|---|
AllocSmall reaching a Gen 2 GC |
Gen 2 budget exceeded by promotions | Mid-life objects, growing caches, leaks |
AllocLarge |
LOH budget exceeded | Large arrays, MemoryStream growth, big strings/JSON |
Induced / InducedNotForced |
GC.Collect() |
App code, a library, a health check, a test helper left in |
LowMemory / LowMemoryHost |
OS or host memory pressure | Container limit too tight, native memory growth |
OutOfSpaceSOH / OutOfSpaceLOH |
Heap could not grow | Near GCHeapHardLimit (default ~75% of a container memory limit) |
| Background Gen 2 (default) | Blocking full GC | |
|---|---|---|
| Threads during marking | Keep running (and can run Gen 0/1 GCs) | Suspended |
| Pause | Short pauses at start and end | Entire GC duration |
| Compaction | No (sweeps, builds free lists) | Can compact |
| When chosen | Normal case | High fragmentation, memory pressure, induced blocking, LowLatency-style decisions |
A blocking full GC on a multi-GB heap with millions of objects can pause for hundreds of milliseconds.
dotnet-counters monitor System.Runtime: Gen 2 count rate, % time in GC, GC heap size, LOH size, fragmentation. Correlate with deploys and traffic.dotnet-trace collect -p <pid> --profile gc-collect (lightweight) for a few minutes during the problem.Induced → search code and dependencies for GC.Collect; capture stacks with gc-verbose or a debugger breakpoint.AllocLarge → allocation tracking filtered to large objects; find the allocating stacks.dotnet-gcdump snapshots minutes apart; diff by type and size.LowMemory / out of space → compare GC heap vs process memory vs container limit; check native memory and GCHeapHardLimit.| Cause | Fix |
|---|---|
| Induced | Remove GC.Collect; use GC.GetGCMemoryInfo() for metrics |
| LOH churn | ArrayPool<T>, RecyclableMemoryStream, streaming, avoid ToArray() on large data |
| Mid-life objects | Stream instead of buffer, bounded caches, avoid finalisers |
| Leak / unbounded growth | Find the root path in a dump and remove the reference |
| Memory limit pressure | Right-size container limits, reduce native memory, consider DATAS / Workstation GC for small pods |
Our [payments API] showed a Gen 2 GC every [two seconds] and a matching latency spike pattern, independent of traffic. dotnet-counters confirmed a constant Gen 2 rate. I took a 60-second gc-collect trace and opened it in PerfView GCStats: every Gen 2 GC had reason Induced. A stack capture led to a metrics library that called GC.Collect() before reporting memory when a "precise" option was enabled. We disabled the option and reported from GC.GetGCMemoryInfo() instead. Gen 2 GCs dropped to [a few per hour], p99 latency fell by [X ms], and I added a CI analyser rule that flags GC.Collect calls.
The interviewer checks a disciplined diagnostic approach to GC problems.
Green flags: what a full GC includes, triggers, background versus blocking, GC reasons from traces, step-by-step investigation, concrete fixes.
Red flags: GC.Collect() as a fix; guesses without traces; thinks every Gen 2 is a long pause.
full GC cost ≈ mark(all live objects + references)
+ (if compacting) copy(live bytes) + update(all references)
+ sweep / free-list building (if not compacting)
A 4 GB heap made of a few large arrays is cheap to mark. A 1 GB heap made of 20 million small objects with many references is expensive. Object count and graph shape matter more than bytes.
app threads ====||=========================================||=====
BGC thread [initial mark]---[concurrent mark]---[final mark]---[sweep]
Gen 0/1 GCs |g0| |g1| |g0|
^ short pause ^ short pause
During a background GC, ephemeral (Gen 0/1) GCs can still run. Allocations continue, so the GC uses a write watch (software write watch tracking) to find objects modified during concurrent marking and re-checks them in the final pause.
GCHeapHardLimit or the container limit (the GC becomes more aggressive and compacts to avoid OOM).GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true) or GCSettings.LargeObjectHeapCompactionMode = CompactOnce followed by a blocking GC.GCMemoryInfo full = GC.GetGCMemoryInfo(GCKind.FullBlocking);
GCMemoryInfo bgc = GC.GetGCMemoryInfo(GCKind.Background);
Console.WriteLine($"Full blocking #{full.Index}: compacted={full.Compacted}, " +
$"pause={full.PauseDurations[0].TotalMilliseconds:N1} ms, " +
$"fragmented={full.FragmentedBytes:N0}, promoted={full.PromotedBytes:N0}");
Console.WriteLine($"Memory load {full.MemoryLoadBytes:N0} of {full.TotalAvailableMemoryBytes:N0} (high threshold {full.HighMemoryLoadThresholdBytes:N0})");
TotalAvailableMemoryBytes reflects the container limit or hard limit, which is a quick way to confirm what the GC thinks it has.
| Knob | Effect | Risk |
|---|---|---|
GCSettings.LatencyMode = SustainedLowLatency |
Avoids blocking full GCs when possible (background only) | Heap can grow larger |
GCSettings.LatencyMode = LowLatency (Workstation only) |
Suppresses Gen 2 GCs for a short critical section | OOM risk if held long |
GC.TryStartNoGCRegion(bytes) |
No GC at all for a pre-reserved allocation budget | Must fit the budget; end it with EndNoGCRegion |
GC.RegisterForFullGCNotification |
Notification before a blocking full GC (e.g. to drain a node from a load balancer) | Complex; rarely used today |
GCConserveMemory (0–9) |
Compacts more aggressively to reduce fragmentation | More GC CPU |
GCHeapHardLimit is roughly 75% of that limit (configurable via GCHeapHardLimit / GCHeapHardLimitPercent).GC.RefreshMemoryLimit() (.NET 8+) lets a process re-read changed limits.GC.Collect callersGC.Collect( and GCSettings.LargeObjectHeapCompactionMode.dotnet-trace collect --profile gc-verbose (includes GC trigger stacks with the right providers) or attach a debugger with a breakpoint on System.GC.Collect.| Version | Change |
|---|---|
| .NET Core 3.0 | GCHeapHardLimit, container awareness, background GC improvements |
| .NET 5 | POH; GCKind on GetGCMemoryInfo |
| .NET 7 | Regions default (64-bit), better memory return to OS |
| .NET 8 | GC.RefreshMemoryLimit(), DATAS opt-in |
| .NET 9 / 10 | DATAS default for Server GC; same full GC triggers |
new byte[200_000] with ArrayPool<byte>.Shared.Rent(200_000) / Return and compare Gen 2 counts.GC.Collect() every 100 iterations and look at the trace: the reason becomes Induced.--memory=256m, allocate and keep 150 MB, and watch Gen 2 frequency and TotalAvailableMemoryBytes.dotnet-trace collect --profile gc-collect and open it in PerfView GCStats; sort by pause time and find which GC type caused the longest pause.GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency and compare the number of blocking full GCs under the same load.using System.Runtime;
int gen2Before = GC.CollectionCount(2);
// LOH churn: many short-lived large arrays trigger Gen 2 GCs
for (int i = 0; i < 2_000; i++)
{
byte[] big = new byte[200_000]; // >= 85,000 bytes: allocated on the LOH
big[i % big.Length] = 1;
}
Console.WriteLine($"Gen 2 GCs caused by LOH churn: {GC.CollectionCount(2) - gen2Before}");
Print("Last background GC", GC.GetGCMemoryInfo(GCKind.Background));
Print("Last full blocking GC", GC.GetGCMemoryInfo(GCKind.FullBlocking));
Console.WriteLine($"Latency mode: {GCSettings.LatencyMode}");
static void Print(string label, GCMemoryInfo info)
{
if (info.Index == 0) { Console.WriteLine($"{label}: none yet"); return; }
Console.WriteLine($"{label}: #{info.Index}, gen {info.Generation}, compacted {info.Compacted}, " +
$"pause {info.PauseDurations[0].TotalMilliseconds:N2} ms, fragmented {info.FragmentedBytes:N0} bytes");
}
dotnet-trace collect -p <pid> --profile gc-collect --duration 00:00:30
# open the .nettrace in PerfView > GCStats to see reason, type and pause per GC
GC.Collect() to control memoryWhat happens: a timer or post-request hook forces collections. Why it is wrong: every call is a blocking full GC that promotes survivors and disrupts tuning; memory often ends up higher. Do instead: find what allocates or survives; let the GC schedule work.
What happens: "the heap is 3 GB, that must be the problem". Why it is wrong: a large stable heap is fine; GC frequency, reason and pause type matter. Do instead: read reasons and types from GC events.
What happens: all Gen 2 GCs are blamed for latency spikes. Why it is wrong: background Gen 2 GCs run concurrently with short pauses. Do instead: separate background from blocking full GCs in the trace and correlate blocking ones with latency.
What happens: the team tunes GC modes without knowing why GCs happen.
Why it is wrong: Induced, AllocLarge and promotion-driven GCs need completely different fixes.
Do instead: collect a gc-collect trace first; fix by reason.
What happens: frequent full GCs near the memory limit are treated as a GC bug. Why it is wrong: the GC is defending against OOM; the limit or native memory is the issue. Do instead: compare GC heap, process memory and limit; right-size or reduce native usage.
What happens: CompactOnce is set before every operation "to keep things tidy".
Why it is wrong: LOH compaction copies large blocks and lengthens blocking GCs.
Do instead: eliminate LOH churn; use one-off compaction only after a known fragmenting phase.
LowLatency mode on serversWhat happens: GCLatencyMode.LowLatency set globally in an ASP.NET Core app.
Why it is wrong: it's Workstation-only for short critical sections; holding it risks unbounded growth.
Do instead: SustainedLowLatency if needed, or TryStartNoGCRegion for very short critical work.
What happens: one gcdump is used to decide "there's a leak". Why it is wrong: one snapshot cannot show growth or churn. Do instead: compare two or three snapshots taken minutes apart under similar load.
What happens: thousands of finalisable objects are created per second.
Why it is wrong: they survive extra GCs, get promoted and add Gen 2 work.
Do instead: dispose deterministically and call GC.SuppressFinalize; avoid custom finalisers.
What happens: a change ships and Gen 2 GCs are assumed fixed. Why it is wrong: GC behaviour depends on load; an unverified fix can regress silently. Do instead: compare the same counters and a new trace under the same load, and keep Gen 2 rate on dashboards.
The GC reason is the most underused piece of diagnostic data.
| Reason seen | First suspect |
|---|---|
| Induced | GC.Collect in code or a library |
| AllocLarge | Large buffers, MemoryStream growth, big JSON |
| Promotion-driven | Mid-life objects or growing caches |
| LowMemory | Container limit too low or native memory growth |
Seniors fix the source of full GCs rather than tuning around them, and keep GC metrics on dashboards so regressions are visible after deploys.
Objects of 85,000 bytes or more (in practice large arrays and strings) are allocated on the LOH. It is collected only during Gen 2 GCs and, by default, is swept, not compacted, so freed space becomes free-list gaps. Two problems follow: churn (frequent large temporary allocations exceed the LOH budget and trigger Gen 2 GCs) and fragmentation (free gaps too small for new large objects, so the heap grows). Fix with `ArrayPool<T>`, RecyclableMemoryStream, streaming instead of buffering, chunked processing, and sizing buffers to stay under the threshold. One-off compaction is available through GCSettings.LargeObjectHeapCompactionMode.
Gen 1 is a small buffer generation. Objects that survive one Gen 0 GC (often just because they were in use at that moment, such as an in-flight request's objects) go to Gen 1, where they get a second chance to die cheaply in a Gen 1 GC instead of being promoted straight to Gen 2. The mid-life crisis is when objects live just long enough to be promoted to Gen 2 and then die soon after: they make Gen 2 grow, trigger expensive full GCs and fragment memory. Typical causes are buffering, slow requests, medium-lived caches and finalisable objects. Fix it by making lifetimes clearly short or clearly long.
RAM is physical memory. Each process gets a virtual address space that the OS maps onto RAM page by page. The working set is what's resident in RAM right now; private (committed) memory is what the process owns. The GC heap is only one part: native allocations (native libraries, NativeMemory, unmanaged buffers), thread stacks, JIT code, and runtime structures add to it. In Kubernetes, the cgroup limit counts all of it, and going over gets the container OOMKilled (exit code 137), not an OutOfMemoryException. So if the GC heap is small but the pod dies, I compare GC heap size with working set in dotnet-counters. A big gap points to native memory or too many threads; a growing GC heap points to a managed leak I'd chase with dotnet-gcdump. Then I fix the cause and set limits and GC settings deliberately.
How to tell it (STAR)
In [our payments API], Gen 2 GCs ran every [two seconds]. The trace showed they were all induced, which led me to a metrics library calling GC.Collect. Turning that off removed almost all full GCs and the latency spikes.
Last updated 31 Oct 2026.