Every .NET developer knows the GC "just works," which is exactly why interviewers use it to separate people who've read the docs from people who've tuned a production heap under load. At the senior and architect level, the questions move past "what is generation 0" into the mechanics that actually explain incidents: why a write to an old object costs more than it looks like, why a perfectly healthy-looking process still pauses for 200ms every few seconds, and why the same code behaves differently in a container than on a workstation. This page works through generations, the physical heap, the GC's operating modes and the diagnostic workflow a lead engineer is expected to run when GC is the suspect, not just the accusation.

Q1 Explain the generational hypothesis behind the .NET GC. Walk through a generation 0 collection end to end.#

Short answer: The GC assumes most objects die young and few live long, so it partitions the heap into generations 0, 1 and 2, collects the youngest generation far more often than the oldest, and promotes anything that survives a collection into the next generation up — which lets a gen0 collection examine and reclaim only a small, cheap slice of the heap instead of scanning everything.

A gen0 collection starts when a thread's allocation context runs out of its budget (typically after filling several 8 KB allocation quanta) and triggers the collector. The GC first suspends managed threads, then runs a mark phase that walks the application's roots — stack slots, CPU registers, static fields, GC handles and the finalization queue — to find every object reachable from gen0 (plus anything gen1/gen2 objects point into gen0 via the write-barrier-tracked card table, covered next). Anything unreached is garbage; anything reached is relocated during a compact phase so that live gen0 survivors end up contiguous at the start of what becomes the new generation 1 region, and the allocation pointer resets to right after them. The GC then recalculates the generation's allocation budget based on the observed survival rate: a high survival rate grows the budget, on the theory that the next collection will find a better ratio of dead to live objects, while a low survival rate keeps it tight. This is why allocating and immediately discarding large numbers of short-lived objects is nearly free relative to native heap allocation — the cost is proportional to what survives, not to what's allocated and freed.

What interviewers look for: that you connect "generational" to a concrete cost model (collection cost scales with survivors, not allocations) rather than reciting "there are three generations" as trivia.

Common mistakes: describing generations as fixed-size regions of memory rather than a logical age classification that's promoted per object, and forgetting that a gen1 or gen2 collection is defined as collecting that generation and every younger one.

Q2 What are card tables and write barriers, and why does a generational collector need them?#

Short answer: A card table is a bitmap the GC maintains alongside older generations; every time managed code writes a reference field, a JIT-inserted write barrier sets the bit ("card") covering that memory region if the new value points into a younger generation, so an ephemeral (gen0/gen1) collection can scan just those flagged regions of gen2 instead of the entire old generation to find cross-generational roots.

Without this bookkeeping, collecting gen0 alone would be unsound: a long-lived gen2 object can hold the only reference to a gen0 object (for example, a static cache that just received a freshly allocated value), and if the collector doesn't know to treat that gen2 field as a root, it would incorrectly reclaim the gen0 object mid-use. Rescanning all of gen2 on every gen0 collection would defeat the entire point of generational collection, so instead every reference assignment goes through a small, inlined helper that checks whether the target address needs its card marked — a cost paid on every reference write, in exchange for making ephemeral collections cheap regardless of how large gen2 has grown. This is precisely why heavy, sustained mutation of long-lived reference-typed fields (a giant in-memory graph that's constantly rewired) is more expensive than the object count alone suggests: every one of those writes pays the write-barrier tax, and if enough cards get marked, gen0 collections start doing meaningfully more scanning work.

C#
// Every assignment like this one goes through a write barrier at the IL/JIT level;
// if `cache` is gen2 and `newItem` is gen0, the barrier marks the covering card.
cache.LatestItem = newItem;

What interviewers look for: understanding write barriers as the mechanism that makes generational collection sound, not just fast — and recognizing the write-heavy-old-object-graph performance implication in real code.

Follow-up questions:

  • What would go wrong if a write barrier were accidentally skipped by unsafe code?
  • Why do card tables matter less for gen1-to-gen2 references than for the ephemeral case?

Q3 How does the GC decide whether an object goes on the small object heap, the large object heap or the pinned object heap — and what changed when the heap moved from segments to regions?#

Short answer: Any allocation request of 85,000 bytes or more goes on the large object heap (LOH, logically generation 2); the pinned object heap (POH) holds only objects explicitly allocated pinned via GC.AllocateArray<T>(..., pinned: true); everything else goes on the small object heap (SOH) starting in generation 0 — and starting in .NET 7, all three are physically organized as regions (4 MB units for SOH, 8x that for LOH/POH by default) instead of the large, generation-labeled segments earlier runtimes used.

The 85,000-byte LOH threshold exists because compacting (copying) very large objects is expensive relative to the benefit, so the LOH is swept rather than routinely compacted — it's only collected alongside a full generation 2 collection, and the runtime does compact it automatically when a heap hard limit is configured or you request it via GCSettings.LargeObjectHeapCompactionMode. The POH exists to solve a specific pain point: before it existed, pinning (for interop buffers, for example) meant pinning an already-allocated, potentially anywhere object, which fragmented the heap and blocked compaction around it; allocating pinned up front instead groups pinned objects together so they stop interleaving with movable ones. The segments-to-regions change is an internal representation shift, not a semantic one for application code: instead of one large contiguous segment per generation that could only grow at one end, the heap is now built from many small, independently reclaimable regions, so a region that empties out can be released back to the OS or handed to a different generation immediately, which reduces the working-set bloat that long segments used to cause when a generation shrank.

What interviewers look for: the specific threshold number and why it exists, not just "big objects go on the LOH" — and awareness that regions are a memory-management implementation detail, useful to know when reading GC diagnostics but not something application code needs to react to.

Common mistakes: assuming the LOH is always compacted like the SOH, or assuming regions changed the generational model itself rather than just its physical backing.

Q4 Compare workstation, server and background GC. What actually goes wrong when you pick the wrong one?#

Short answer: Workstation GC runs collection on the same thread that triggered it and is tuned for low resource usage in client-style apps; server GC creates one heap and one dedicated, high-priority collection thread per logical core and is tuned for throughput on multi-core server workloads; background GC (the default subflavor for both) lets ephemeral (gen0/gen1) collections continue to run concurrently with a gen2 collection instead of blocking behind it, cutting the worst-case pause.

Server GC's per-core heap model is exactly why it's the wrong default for a high-density host: if a dozen server-GC processes on a four-core box all decide to collect at once, that's up to 12 concurrent GC threads competing for four cores, which produces worse pause times than workstation GC would have — a mistake that shows up constantly in over-provisioned container clusters running one small ASP.NET Core service per pod with server GC left on by default. Background GC changes only the gen2 story: the ephemeral generations were never blocked by a full collection for long even before it existed, but background GC lets a dedicated thread (or threads, under server GC) walk gen2 concurrently, pausing only briefly at the start (to establish the mark) and to interleave short "foreground" gen0/gen1 collections as needed, rather than freezing the whole process for the entire gen2 sweep. The practical rule of thumb: server GC for a dedicated, few-processes-per-machine service that wants maximum throughput; workstation GC (often with concurrent GC left on) for anything sharing a machine with many other processes, including most containerized microservices below a certain replica density — and DATAS, discussed next, exists specifically to soften this decision.

What interviewers look for: the container/density failure mode specifically — it's the most common real production GC misconfiguration, and naming it unprompted signals hands-on operational experience.

Q5 What problem does DATAS solve, and how does it change the classic "one heap per core" server GC model?#

Short answer: Dynamic adaptation to application sizes (DATAS) makes server GC size its heap and its heap count to the application's actual long-lived data volume instead of assuming the process should aggressively claim memory proportional to core count; it starts with a single heap and grows or shrinks the heap count as allocation pressure changes, and it's enabled by default starting in .NET 9.

Classic server GC optimizes purely for throughput: it treats the process as the dominant workload on the machine and will grow the heap aggressively when memory is available, with no built-in pressure to shrink back down when the workload gets lighter — on a 48-core machine that can mean a working set far larger than the app's actual live data needs, which is expensive in memory-constrained, high-density container environments and complicates capacity planning. DATAS instead ties the maximum gen0 allocation budget to an estimate of long-lived data size, adjusts the number of active heaps dynamically (as few as one, as many as core count) based on real allocation contention rather than assuming every core needs its own heap from the start, and performs full compacting collections when needed to keep fragmentation from inflating the heap artificially. The trade-off, and the reason it's a setting you should know how to turn off (System.GC.DynamicAdaptationMode / DOTNET_GCDynamicAdaptationMode=0), is a modest throughput cost — official benchmarks show roughly a 2-3% reduction in maximum throughput in exchange for well over 80% working-set improvement on bursty workloads, which is a trade most services should take but a small number of pure-throughput services shouldn't.

What interviewers look for: knowing DATAS by name, its default-on status, and that it's specifically a response to the "server GC assumes it owns the machine" problem from the previous question — this is a live, current-release detail that filters candidates who track the runtime versus those working from years-old mental models.

Q6 What are the GC latency modes, and what does GC.TryStartNoGCRegion actually guarantee?#

Short answer: GCLatencyMode trades collection thoroughness for predictability — LowLatency suppresses gen2 collections for short, workstation-only windows, SustainedLowLatency does the same for longer windows on both workstation and server GC by relying on background gen2 collection — while TryStartNoGCRegion asks the GC to guarantee no collection at all will happen during a critical section, which it enforces by pre-allocating enough budget up front and simply failing the request if it can't.

These modes exist for the same reason: some code has a short window where a stop-the-world pause is unacceptable (rendering a frame, executing a latency-sensitive trade), and the GC can be told to be more conservative about reclaiming memory during that window, at the cost of the process's overall memory footprint growing faster while the mode is active. TryStartNoGCRegion is the strictest tool: you request a byte budget, and the runtime either proactively performs a full collection to make sure that much headroom exists and then genuinely suspends generation 2 collection guarantees for the region, or it returns false/throws if it cannot satisfy the request — it will not silently degrade into "mostly no GC." Critically, none of these modes are a free lunch: a low-memory notification from the OS or an explicit GC.Collect(2) call can still force a gen2 collection even inside LowLatency mode, and staying in SustainedLowLatency for a long time produces a larger, less-compacted heap because the mode leans on non-compacting background collection.

C#
if (GC.TryStartNoGCRegion(64 * 1024 * 1024))
{
    try
    {
        RunLatencyCriticalWindow();
    }
    finally
    {
        GC.EndNoGCRegion();
    }
}

What interviewers look for: the distinction between "less GC" (the latency modes) and "provably no GC, or a hard failure" (NoGCRegion), plus awareness that these tools shrink a window of risk, they don't eliminate GC from the process.

Common mistakes: treating SustainedLowLatency as free performance with no downside, or assuming TryStartNoGCRegion silently falls back to normal behavior when it can't honor the request.

Q7 Walk through finalization end to end. Why can an object with a finalizer take two garbage collections to actually free its memory?#

Short answer: When a collection discovers an unreachable object whose type overrides Finalize, it doesn't reclaim it — it moves the object from "unreachable" to the finalization queue, which counts as a root, so the object (and everything it references) survives that collection; only after a dedicated finalizer thread runs the finalizer and the object becomes unreachable again in a subsequent collection is its memory actually reclaimed.

This two-pass behavior is the entire reason the guidance is "finalizers are a safety net, not a primary cleanup mechanism": every finalizable object is promoted at least one generation further than it otherwise would have been (because it survives an extra collection while sitting in the finalization queue), and if the finalizer thread is slow or backed up — for instance because a finalizer is doing blocking I/O, which it never should — the queue backs up and finalizable objects pile up, unreclaimed, behind it. The IDisposable pattern exists specifically to let code opt out of this cost deterministically: Dispose() performs cleanup immediately and calls GC.SuppressFinalize(this), which removes the object from the finalization queue so the next collection can reclaim it in one pass like any ordinary object, exactly as if it had never had a finalizer. An empty or redundant finalizer (one that only calls the base finalizer, or is conditionally compiled out) still pays this promotion cost for nothing, which is why the guidance against empty finalizers is stricter than it might first sound — it's not a style nitpick, it's a measurable tax on every instance.

C#
public sealed class NativeBuffer : IDisposable
{
    private IntPtr _handle;

    public void Dispose()
    {
        ReleaseHandle();
        GC.SuppressFinalize(this); // skip the finalization-queue detour entirely
    }

    ~NativeBuffer() => ReleaseHandle(); // safety net only, for callers who forgot Dispose

    private void ReleaseHandle() { /* free native resource */ }
}

What interviewers look for: the two-collection mechanic specifically, and the direct link between that mechanic and why Dispose + SuppressFinalize is the correct pattern rather than "just implement a finalizer to be safe."

Q8 When would you reach for a WeakReference, and what's the practical difference between a short and a long weak reference?#

Short answer: Use a WeakReference when you want to let the GC reclaim an expensive-to-hold object under memory pressure while still being able to cheaply reconstruct or refetch it if it's still around — a rebuildable cache entry is the canonical case; a short weak reference clears the moment the object is found unreachable, while a long weak reference (constructed with trackResurrection: true) stays valid until after the object's finalizer has already run, which means the object it points to may be in a partially torn-down state.

The short/long distinction matters because it changes what you're allowed to assume about the target: a short weak reference's Target is either a fully valid, live object or null — there's no in-between state to worry about. A long weak reference can hand you back an object whose finalizer already executed, meaning any unmanaged resources it owned may already be released, so resurrecting and reusing it safely requires the type to tolerate being "used after Finalize," which is a much narrower contract than most types actually implement — this is why the guidance is to reach for a long weak reference only when you specifically need it, not as a default. WeakReference itself is a small managed object, so weak-referencing something tiny can cost more in overhead than it saves; it's a tool for genuinely large, reconstructable objects (a parsed document tree, a decoded image, a materialized report), not a general substitute for a proper eviction policy in a cache.

What interviewers look for: the specific "may observe a post-finalization object" hazard of long weak references, since that's the detail that separates someone who's read the API surface from someone who's actually reasoned about what it implies.

Common mistakes: using WeakReference as a lazy substitute for IDisposable/explicit lifetime management, or assuming a weak reference guarantees the object stays alive until the next GC rather than potentially clearing at the very next collection of any generation.

Q9 How does the GC behave inside a container, and how do you correctly size a heap hard limit for a containerized .NET service?#

Short answer: By default the GC reads the container's memory limit (cgroup on Linux, Job object on Windows containers) as if it were the machine's total physical memory, and — unless you set an explicit hard limit — it caps itself at 75% of that limit automatically; getting containerized sizing right is mostly about not fighting that default with a mismatched requests/limits configuration rather than hand-tuning byte counts.

The practical failure mode is a Kubernetes pod whose memory limit is set well above its memory request: the GC sees the higher limit, happily grows the heap toward 75% of it during a load spike, and then the container gets OOM-killed by the orchestrator's cgroup enforcement — not because the app leaked, but because the GC was never told the realistic ceiling. The fix is either tightening requests and limits to be close together so the GC's view of "available memory" matches what the pod can actually sustain, or explicitly setting System.GC.HeapHardLimit/DOTNET_GCHeapHardLimitPercent (or the per-heap SOH/LOH/POH variants) to a value with real headroom below the container limit, accounting for native and thread-stack memory the GC's heap accounting doesn't cover. Server GC's heap count also reacts to the CPU limit (not just memory) in a container, so a pod pinned to one or two vCPUs but sized for a 32-core node's default heap count will over-provision GC heaps relative to available CPU, which is exactly the scenario DATAS was built to soften; setting System.GC.HeapCount explicitly is the manual equivalent when DATAS is disabled or unavailable.

What interviewers look for: the requests-versus-limits framing specifically — it's the actual cause behind most "random OOMKilled" tickets involving .NET pods, and interviewers use it to check for real Kubernetes/container operational exposure, not just GC trivia.

Q10 A service has healthy CPU and memory graphs, but p99 latency spikes correlate with GC. How do you actually confirm that and find the fix?#

Short answer: Confirm causation before chasing configuration: capture a dotnet-trace (or ETW/EventPipe) session covering a spike window, correlate GC pause events against the latency spike timestamps, and use dotnet-counters' % Time in GC and gen0/gen1/gen2 collection-rate counters to see whether pauses line up with the tail latency — only then look at allocation rate, generation sizes and mode as the levers to pull.

If the correlation holds, the next question is which generation is driving it: a high gen0 collection rate with short individual pauses usually points to allocation rate — too many short-lived objects per request, often boxing, string concatenation, or LINQ allocating enumerators and closures in a hot path — and the fix is reducing allocations, not tuning the GC. Long, infrequent pauses that line up with gen2/full collections point somewhere else: a slowly growing LOH from repeated large buffer allocations, a background GC that's being starved by CPU contention (worth checking against the server-GC-thread-count-versus-core-count mismatch from the container question above), or genuine long-lived object growth that keeps raising the gen2 threshold. dotnet-gcdump is the tool for the second category — it captures a point-in-time heap snapshot you can inspect for unexpectedly large object counts or a growing retained-object graph, which is how you tell "the heap is fine, GC is just doing its job on real load" from "there's a slow leak inflating gen2 and every full collection gets more expensive." Only after this evidence-gathering does it make sense to change GC mode, adjust the LOH threshold, pool large buffers with ArrayPool<T>, or move to server GC or DATAS — changing GC settings before confirming GC is the actual cause is the single most common mistake in this diagnostic path.

Bash
dotnet-counters monitor --process-id 1234 System.Runtime[gen-0-gc-count,gen-1-gc-count,gen-2-gc-count,time-in-gc]
dotnet-trace collect --process-id 1234 --providers Microsoft-Windows-DotNETRuntime:0x1:5

What interviewers look for: an evidence-first diagnostic sequence — correlate, then classify by generation, then act — rather than jumping straight to "switch to server GC" or "increase the heap," which is the answer that reveals someone hasn't actually run this investigation before.

Quick-Fire Round#

QuestionAnswer
What's the byte threshold for an object to go on the LOH?85,000 bytes.
What physically changed about the heap starting in .NET 7?Segments were replaced by regions (4 MB default for SOH).
What data structure lets ephemeral GCs skip scanning all of gen2?The card table, maintained by write barriers.
What's the default GC flavor for a containerized ASP.NET Core app?Server GC (host-determined; can differ from standalone apps).
What does DATAS change about server GC's heap count?It starts at one heap and grows/shrinks it dynamically instead of fixing it to core count.
What guarantees does GC.TryStartNoGCRegion provide on success?No collection will occur until the region ends or the budget is exceeded.
Why does a finalizable object often take two collections to free?It survives the first collection by landing on the finalization queue.
What does GC.SuppressFinalize actually do?Removes the object from the finalization queue so it collects normally.
What percentage of a container's memory limit is the default heap hard limit?75%.
What tool captures a point-in-time heap snapshot for leak analysis?dotnet-gcdump.

How to Prepare#

  • Be able to describe a gen0 collection's mark/compact steps without notes, including where the allocation budget comes from.
  • Practice explaining why write barriers exist in terms of soundness, not just performance.
  • Know the exact default heap hard limit percentage in containers and why requests-versus-limits mismatches cause OOMKilled pods.
  • Rehearse DATAS's default-on status and the throughput-versus-working-set trade it makes, since it's a current-release detail many candidates miss.
  • Have one real dotnet-trace/dotnet-counters diagnostic story ready — interviewers weight a lived investigation far above textbook GC facts.
  • Practice the finalization-to-IDisposable explanation as a single, tight paragraph; it's asked in nearly every senior .NET loop.