📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is a Gen 2 (full) garbage collection in .NET, what triggers it, and how do you investigate frequent Gen 2 GCs?

Full GCs, triggers (budget, LOH allocation, induced, low memory), blocking versus background, compaction, and a method to diagnose frequent Gen 2 collections.

Senior Debugging Advanced diagnostics full-gc gc gen2 performance

🎯 Key points

  • A Gen 2 GC collects the whole heap including LOH and POH
  • Triggers: Gen 2 budget from promotion, LOH allocation, low memory and induced GC.Collect
  • Background Gen 2 GCs run concurrently; blocking full GCs can compact and pause longer
  • Investigate with GC reasons and types from dotnet-trace and PerfView GCStats
  • Follow the reason: induced, AllocLarge, promotion or low memory
  • LOH compaction is opt-in with LargeObjectHeapCompactionMode.CompactOnce
  • Never use GC.Collect as a memory fix

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"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."


1. What a full GC includes

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.

2. Triggers and what each points to

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)

3. Background vs blocking full GCs

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.

4. Investigation method

  1. Confirm and size it with dotnet-counters monitor System.Runtime: Gen 2 count rate, % time in GC, GC heap size, LOH size, fragmentation. Correlate with deploys and traffic.
  2. Capture GC events: dotnet-trace collect -p <pid> --profile gc-collect (lightweight) for a few minutes during the problem.
  3. Open in PerfView → GCStats (or TraceEvent / another trace viewer) and, for each Gen 2 GC, read: reason, type (background/blocking/non-concurrent), pause, promoted MB, heap size before/after, fragmentation.
  4. Follow the reason:
    • 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.
    • Promotion-driven → two or three 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.
  5. Fix the source, then verify with the same counters and a new trace.

5. Typical fixes by cause

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

Production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. The cost of a full GC

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.

2. Background GC timeline

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.

3. When the GC chooses a blocking compacting full GC

  • Fragmentation in Gen 2 is high and free lists can't satisfy allocations well.
  • The process is close to 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.
  • Provisional decisions: an ephemeral GC can be escalated to a full GC based on tuning (seen as Gen 2 with an ephemeral-looking reason).

4. Reading GC data in-process

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.

5. Latency control knobs (use with care)

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

6. Containers and hard limits

  • With a container memory limit, the default GCHeapHardLimit is roughly 75% of that limit (configurable via GCHeapHardLimit / GCHeapHardLimitPercent).
  • Approaching it, the GC becomes much more aggressive: more Gen 2 GCs, more compaction.
  • GC.RefreshMemoryLimit() (.NET 8+) lets a process re-read changed limits.
  • A pod that is OOM-killed with a small GC heap usually has native memory growth, which full GCs cannot fix.

7. Finding GC.Collect callers

  • Search source: GC.Collect( and GCSettings.LargeObjectHeapCompactionMode.
  • At runtime: collect 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.
  • Libraries: older image-processing, reporting and "memory reporting" libraries are frequent offenders.

8. Version notes

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

9. Try it yourself

  1. Run the code example and note how many Gen 2 GCs LOH churn produces, and the details of the last background and full blocking GCs.
  2. Replace new byte[200_000] with ArrayPool<byte>.Shared.Rent(200_000) / Return and compare Gen 2 counts.
  3. Add GC.Collect() every 100 iterations and look at the trace: the reason becomes Induced.
  4. Run the app in Docker with --memory=256m, allocate and keep 150 MB, and watch Gen 2 frequency and TotalAvailableMemoryBytes.
  5. Capture 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.
  6. Set GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency and compare the number of blocking full GCs under the same load.

💻 Code

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

⚠ Common mistakes

1. Calling GC.Collect() to control memory

What 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.

2. Diagnosing from heap size alone

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.

3. Assuming every Gen 2 GC is a long pause

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.

4. Not reading the GC reason

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.

5. Blaming the GC when the container limit is too small

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.

6. Turning on LOH compaction permanently

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.

7. Using LowLatency mode on servers

What 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.

8. Taking a single heap snapshot

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.

9. Ignoring finalisers

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.

10. Not verifying the fix

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate finds AllocLarge as the GC reason Why: Checks LOH diagnosis and fixes
    How to answer

    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.

  2. When: Candidate finds promotion-driven Gen 2 GCs Why: Probes mid-life lifetimes
    How to answer

    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.

  3. When: Candidate mentions LowMemory near container limits Why: Checks OOMKilled diagnosis
    How to answer

    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.

🛠 Real-world problems: how you handle them

A metrics library induced a Gen 2 GC every two seconds
Situation
A payments API showed a Gen 2 GC every [two seconds] and periodic latency spikes.
Signals
`dotnet-counters` showed a constant Gen 2 rate unrelated to traffic.
Investigation
PerfView GCStats from a `dotnet-trace` showed reason `Induced` for all Gen 2 GCs; a stack search found a metrics reporter calling `GC.Collect()` for a precise memory reading.
Resolution
Disabled the precise option and used `GC.GetGCMemoryInfo()` instead.
Prevention
Added a CI check for `GC.Collect` in code and a dashboard of GC reasons from traces.

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.

↩ Usually asked after

More in Garbage Collection

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me