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

📚 Interview question

How does generational garbage collection work in .NET, and why does it make most collections cheap?

The generational hypothesis, Gen 0/1/2, promotion, card tables and write barriers, regions, and mark-sweep-compact.

Mid-Level Theory Intermediate clr gc generations memory

🎯 Key points

  • The heap is split into Gen 0, Gen 1 and Gen 2; LOH and POH are logically part of Gen 2
  • Most objects die young, so young collections are cheap
  • GC cost is proportional to live objects, not garbage
  • Survivors are promoted; collecting generation N also collects younger ones
  • Write barriers and card tables track old-to-young references
  • Since .NET 7 the heap uses regions instead of segments
  • Observe with GC.CollectionCount, GetGCMemoryInfo and dotnet-counters

⏱ 30-second answer

The .NET GC divides the managed heap into Gen 0 (new objects), Gen 1 (a buffer for objects that survived one GC) and Gen 2 (long-lived objects, plus the LOH and POH logically). It relies on the generational hypothesis: most objects die young. A Gen 0 GC only examines young objects, so its cost is proportional to the few survivors, not to the whole heap. Survivors are promoted to the next generation. References from old objects to young ones are tracked by a write barrier and card table so a young GC does not have to scan Gen 2. Collecting generation N also collects all younger ones. Since .NET 7 the heap is organised in regions rather than large segments.

🗣 Full answer (senior)

How I would answer this in an interview

"The .NET GC is a tracing, generational, mostly compacting collector. It splits the heap into Gen 0, Gen 1 and Gen 2 because most objects die young. A young collection only has to find the few survivors among recently allocated objects, and its cost is proportional to those survivors, not to the garbage. Survivors get promoted. A write barrier and card table let a young GC ignore most of Gen 2. That combination is why the vast majority of collections in a healthy service are cheap."

Then I walk through the pieces below, and finish with how I observe it in production.


1. The generational hypothesis

Empirically, across almost every kind of program:

  • Most objects die very young (request DTOs, LINQ iterators, strings built for a log line, closures).
  • Objects that survive a while tend to live a long time (caches, singletons, configuration, connection pools).

The GC exploits this by collecting young objects often and old objects rarely.

2. The generations

Generation What lives there How it gets there Collected when
Gen 0 Newly allocated small objects (< 85,000 bytes) new Gen 0 allocation budget is exhausted (very frequent)
Gen 1 Objects that survived one GC Promoted from Gen 0 Gen 1 budget exhausted, or a Gen 2 GC
Gen 2 Long-lived objects Promoted from Gen 1 Gen 2 budget exhausted, memory load, GC.Collect(), low-memory signals
LOH Objects >= 85,000 bytes Allocated directly Logically collected with Gen 2
POH Arrays allocated with GC.AllocateArray(..., pinned: true) Allocated directly Logically collected with Gen 2, never compacted

Key rule: collecting generation N also collects every younger generation. A Gen 1 GC collects Gen 0 and Gen 1; a Gen 2 GC ("full GC") collects everything.

3. What a collection actually does

  1. Suspend managed threads at GC safe points.
  2. Mark reachable objects starting from roots:
    • stack slots and CPU registers of every thread (the JIT records where references live at each safe point),
    • static fields,
    • GC handles (strong and pinned),
    • the finalisation queue,
    • for a young GC, marked cards in older generations.
  3. Plan: decide whether to compact (slide survivors together) or sweep (build free lists).
  4. Relocate and update references (if compacting) or sweep.
  5. Promote survivors one generation.
  6. Resume threads.

4. Why young collections are cheap

Cost driver Gen 0 GC Full GC
Objects to mark Only live young objects Every live object in the heap
Old-generation scanning Only dirty cards Everything
Memory reclaimed Whole Gen 0 space becomes free in bulk Spread across the heap
Typical pause Sub-millisecond to a few ms Tens to hundreds of ms on big heaps (less with background GC)

The important line: garbage costs nothing to collect. The GC never visits dead objects individually during a compacting young GC. If 99% of Gen 0 is garbage, the GC copies the 1% survivors and the entire region is available again.

5. Old-to-young references: write barrier and card table

If a Gen 2 object points at a Gen 0 object, a Gen 0 GC must treat that pointer as a root, but it cannot afford to scan all of Gen 2 to find it.

  • Every time your code stores a reference into a heap object field, the JIT emits a call to a write barrier.
  • The write barrier marks a card (a byte covering a small address range) when the stored reference might point to a younger generation.
  • A young GC scans only the dirty cards in older generations.

That is why storing references into long-lived objects is not completely free: it costs a barrier on every write, and many dirty cards make young GCs slower.

6. Regions (.NET 7 and later)

On 64-bit, .NET 7 replaced the old few-big-segments design with regions: many fixed-size chunks, each owned by a generation.

  • Promotion can often happen by reassigning a whole region to an older generation instead of copying.
  • Free regions return to a shared pool and can be decommitted, so memory goes back to the OS more readily.
  • Generation sizes are more flexible, which helps containers and DATAS (dynamic Server GC sizing in .NET 9+).

7. How I observe it

Tool What I look at
dotnet-counters monitor System.Runtime GC count per generation, % time in GC, allocation rate, heap size, fragmentation
GC.GetGCMemoryInfo() Heap size, fragmented bytes, pause durations, promoted bytes, generation sizes
GC.GetTotalPauseDuration() Cumulative GC pause time
dotnet-trace with the GC keyword / PerfView Per-GC reason, generation, pause, promoted bytes

A healthy request/response service: Gen 0 counts climb steadily, Gen 1 at a fraction of that, Gen 2 rare, % time in GC in low single digits.

Production example

In [our product catalogue API], dashboards showed about 2% time in GC, almost all Gen 0 with sub-millisecond pauses. After a release that added a five-minute static cache of per-request computation results, Gen 2 GCs went from [a few per hour] to [several per minute] and p99 latency roughly doubled. Because I understood generations, the question was "what changed object lifetimes?" rather than "what allocates more?". A dotnet-gcdump showed [millions of] cached result objects in Gen 2. We replaced the cache with a size-limited MemoryCache storing compact keys, and Gen 2 GCs went back to [a few per hour].

🔍 What the interviewer is checking

The interviewer checks a solid mental model of .NET memory management.

Green flags: generational hypothesis, promotion, roots, cost proportional to survivors, write barrier and card table, compaction versus sweep, regions, observation tools.

Red flags: reference counting; every GC scans everything; recommends GC.Collect().

📚 Learn in depth

1. The cost model in one formula

Approximate cost of a compacting young GC:

cost ≈ (roots scanned) + (dirty cards scanned) + (bytes of surviving objects copied) + (references updated)

Note what is missing: the number of dead objects. This is the entire reason generational, compacting collectors win for allocation-heavy managed code. Contrast with malloc/free, where every dead object costs a free call.

2. Allocation is a pointer bump

Each thread owns an allocation context: a small chunk of Gen 0 space with an alloc_ptr and alloc_limit.

Gen 0 region
+----------------------------------------------------------+
| obj | obj | obj | obj |  free in this allocation context  |
+----------------------------------------------------------+
                        ^alloc_ptr                ^alloc_limit

new in the fast path is roughly:

// pseudo-code of the JIT allocation helper fast path
if (alloc_ptr + size <= alloc_limit)
{
    obj = alloc_ptr;
    alloc_ptr += size;
    obj->MethodTable = mt;   // memory is already zeroed
    return obj;
}
return SlowPathAllocate(size); // get a new allocation context, maybe trigger a GC

The slow path takes a new chunk, and when the Gen 0 budget for this GC cycle is used up, it triggers a Gen 0 GC. The budget is dynamic: the GC tunes it from survival rates and cache sizes.

3. What the JIT contributes: GC info and safe points

The GC must know exactly which stack slots and registers hold object references. The JIT emits GC info for every method describing live references at each safe point. That is how .NET can be a precise (not conservative) collector, and why it can move objects: it knows every reference to update.

Thread suspension works by getting each thread to a safe point (at a call return, via return-address hijacking, or by polling in loops) before the GC begins.

4. The write barrier in practice

This C#:

_cache.LastItem = item; // _cache lives in Gen 2, item is new

compiles to IL stfld. The JIT does not emit a plain mov; it emits a call to a write barrier helper, conceptually:

JIT_WriteBarrier(&_cache.LastItem, item):
    *dst = item
    if item is in the ephemeral range (Gen 0/1):
        card_table[dst >> card_shift] = 0xFF   // mark card dirty

There are several barrier variants the runtime swaps in (precise vs non-precise, with or without software write watch for background GC). Two practical consequences:

  • Writes of references into heap objects cost a few extra instructions. Writes of int or into locals do not.
  • Structs containing references stored into arrays also trigger barriers (one per reference field).

5. Mark, plan, relocate, compact

Before Gen 0 GC (one Gen 0 region)
[A][x][x][B][x][x][x][C][x][x]     A, B, C reachable; x garbage

After compaction
[A][B][C][          free           ]
          ^ promoted to Gen 1 (or the region itself is reassigned)
  • Mark: set a mark bit on each reachable object (in the object header/method table pointer bits).
  • Plan: build a plan of where each survivor will move, using a brick table to find objects quickly, and decide compact vs sweep.
  • Relocate: rewrite every reference (in stacks, handles, other objects) to the new addresses.
  • Compact: move the bytes.

The GC decides compact vs sweep each time. Sweep is faster for the GC itself but leaves fragmentation (free list holes); compaction costs copying but gives future allocations contiguous space.

6. Segments vs regions

Segments (before .NET 7) Regions (.NET 7+ on 64-bit)
Unit A few large segments; ephemeral segment holds Gen 0/1 Many fixed-size regions per generation
Promotion Mostly by copying across generation boundaries Can reassign whole regions
Returning memory Harder, especially with a pinned object in a segment Free regions go to a pool and can be decommitted
Fragmentation from pins One pin could hold a large segment Contained to one region

You can still opt back to segments with configuration (clrgc.dll / DOTNET_GCName) for diagnosis, but regions are the default and the right choice.

7. GC triggers

Trigger Typical generation
Gen 0 allocation budget exceeded Gen 0 (may escalate)
Gen 1 budget exceeded during a GC Gen 1
Gen 2 / LOH budget exceeded Gen 2 (often background)
High memory load (near container limit or GCHeapHardLimit) More aggressive, compacting Gen 2
GC.Collect() Whatever you asked, blocking by default
OS low-memory notification Gen 2

The GC may also escalate a requested generation (for example a Gen 1 becomes Gen 2) based on its tuning.

8. Version notes

Version Change
.NET Core 3.0 GCHeapHardLimit and container awareness; POH arrived in .NET 5
.NET 5 Pinned Object Heap
.NET 7 Regions default on 64-bit; GC.GetTotalPauseDuration()
.NET 8 DATAS available as opt-in for Server GC; GCConserveMemory matured
.NET 9 DATAS on by default for Server GC
.NET 10 Continued GC tuning; the generational model above is unchanged

9. Try it yourself

  1. Run the code example and confirm the survivor moves 0 → 1 → 2 while the loop only increments the Gen 0 count.
  2. Run dotnet-counters monitor -n YourApp System.Runtime against a small ASP.NET Core app under load (bombardier or k6), and note Gen 0/1/2 rates and % time in GC.
  3. Add a static List<byte[]> that keeps every tenth allocation for 30 seconds. Watch Gen 2 count and heap size climb.
  4. Call GC.GetGCMemoryInfo(GCKind.Ephemeral) and GCKind.FullBlocking and compare PauseDurations.
  5. Collect a trace with dotnet-trace collect --profile gc-verbose and open it in PerfView's GCStats view; find the trigger reason for each GC.
  6. Write a micro-benchmark with BenchmarkDotNet and [MemoryDiagnoser] that writes references into a long-lived array versus a local array; compare timings to see write barrier cost.

💻 Code

var survivor = new byte[100];
Console.WriteLine($"New object generation: {GC.GetGeneration(survivor)}");   // 0

GC.Collect(0);
Console.WriteLine($"After Gen 0 GC: {GC.GetGeneration(survivor)}");         // 1

GC.Collect(1);
Console.WriteLine($"After Gen 1 GC: {GC.GetGeneration(survivor)}");         // 2

int gen0Before = GC.CollectionCount(0);
int gen2Before = GC.CollectionCount(2);

// Short-lived garbage: dies in Gen 0
for (int i = 0; i < 1_000_000; i++)
{
    var temp = new byte[64];
    temp[0] = (byte)i;
}

Console.WriteLine($"Gen 0 GCs during loop: {GC.CollectionCount(0) - gen0Before}");
Console.WriteLine($"Gen 2 GCs during loop: {GC.CollectionCount(2) - gen2Before}");

GCMemoryInfo info = GC.GetGCMemoryInfo();
Console.WriteLine($"Heap size: {info.HeapSizeBytes:N0} bytes, pause time so far: {GC.GetTotalPauseDuration().TotalMilliseconds:N1} ms");
GC.KeepAlive(survivor);

GC.Collect is used here only to demonstrate promotion; do not call it in production code.

⚠ Common mistakes

1. Thinking every GC scans the whole heap

What happens: candidates say "the GC stops everything and walks all objects". Why it is wrong: Gen 0 and Gen 1 GCs visit only young live objects plus dirty cards. That is the whole point of generations. Do instead: explain young vs full GCs and why young ones are cheap.

2. Saying GC cost is proportional to garbage

What happens: developers try to "free" objects early by setting fields to null everywhere. Why it is wrong: compacting young GCs do not touch dead objects; cost tracks survivors. Do instead: focus on how much survives and for how long, not on how much is discarded.

3. Describing .NET GC as reference counting

What happens: "objects are freed when the last reference goes away", plus fear of cycles. Why it is wrong: .NET traces from roots; cycles with no root path are collected normally. Do instead: describe roots → mark → compact/sweep.

4. Calling GC.Collect() to help

What happens: code calls GC.Collect() after big operations or on a timer. Why it is wrong: it forces a blocking full GC, promotes everything currently live (making it older and more expensive later), and resets the GC's tuning. Do instead: fix lifetimes and allocation patterns; use GC.Collect only in tests, benchmarks or very specific, measured scenarios.

5. Creating medium-lived objects without realising

What happens: per-request objects are stored in a static cache or queue for seconds to minutes. Why it is wrong: they survive Gen 0 and Gen 1, reach Gen 2, then die, which drives frequent full GCs. Do instead: keep request data per request, or make cached data bounded and genuinely long-lived (see the "mid-life crisis" question).

6. Ignoring the write barrier

What happens: hot loops write millions of references into a long-lived array or object graph. Why it is wrong: every reference store into the heap runs a write barrier and dirties cards, making young GCs slower. Do instead: prefer value types or indexes for hot data, or build data locally then publish once.

7. Assuming the LOH and POH are separate generations

What happens: people talk about "Gen 3". Why it is wrong: LOH and POH are separate heaps, but they are collected only with Gen 2. Some runtime APIs index them as 3 and 4, but logically they are Gen 2. Do instead: say "LOH and POH are collected with Gen 2".

8. Quoting segment-era internals as current

What happens: candidates describe "the ephemeral segment" as how .NET 8/10 works. Why it is wrong: since .NET 7 the default on 64-bit is regions. Do instead: mention regions and what they improve (decommit, pin containment, flexibility).

9. Measuring memory with Task Manager only

What happens: "working set is 2 GB so we have a leak". Why it is wrong: working set includes committed but free GC memory, native memory and code. It does not show generations. Do instead: use dotnet-counters, GC.GetGCMemoryInfo() and a gcdump.

10. Treating all GC pauses the same

What happens: a team sees "GC pauses" and switches GC modes randomly. Why it is wrong: frequent tiny Gen 0 pauses and rare long full blocking GCs have different causes and fixes. Do instead: break down GCs by generation, type (blocking/background) and trigger reason before changing anything.

🏛 Senior insight: trade-offs

Generational GC works well when object lifetimes are either very short or very long.

Lifetime pattern GC effect
Request-scoped, dies quickly Cheap Gen 0 collections
Application lifetime (caches loaded at start) Lives in Gen 2, rarely scanned
Medium lifetime (seconds to minutes) Survives to Gen 2 then dies: expensive full GCs
References from old to many new objects Card marking overhead

Senior engineers design lifetimes deliberately: keep per-request data per request, and make long-lived data truly long-lived and stable.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says Gen 0 GCs are cheap Why: Checks allocation and Gen 0 mechanics
    How to answer

    Each thread allocates small objects from its own allocation context, a chunk of Gen 0 memory, by bumping a pointer: allocation is a few instructions and needs no lock. When the context is used up the thread gets a new chunk; when the total allocated since the last GC exceeds the Gen 0 budget (sized dynamically, related to CPU cache sizes and GC mode), a Gen 0 GC runs. It marks survivors, promotes them to Gen 1 and frees the rest. Gen 0 GCs are short, but they still pause threads, so a high allocation rate means many pauses. Reduce them by allocating less: spans, pooling, avoiding LINQ and boxing in hot paths, and reusing buffers.

  2. When: Candidate mentions promotion to Gen 1 Why: Probes Gen 1 and mid-life objects
    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 says full GCs are expensive Why: Checks how to investigate Gen 2 GCs
    How to 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.

  4. When: Candidate mentions LOH and POH Why: Checks managed heap layout
    How to answer

    Each thread allocates from its own allocation context, a small chunk of gen 0 where new is just a pointer bump plus zeroing; when it runs out, the thread asks the GC for another chunk, and when the gen 0 budget is used up, a gen 0 GC runs. The GC is generational because most objects die young: gen 0 collections are cheap, survivors are promoted to gen 1 and then gen 2, which is collected rarely (and mostly in the background). Objects of 85,000 bytes or more go to the large object heap (LOH), which is collected only with gen 2 and isn't compacted by default, so it can fragment. Pinned objects can't be moved, which blocks compaction; the pinned object heap (.NET 5+) keeps long-lived pinned buffers separate. In hot paths I reduce the allocation rate rather than tuning the GC.

🛠 Real-world problems: how you handle them

Medium-lived cache entries turned cheap Gen 0 GCs into frequent Gen 2 GCs
Situation
A product API added a 5-minute static cache of per-request computation results.
Signals
Gen 2 GC count rose from [a few per hour] to [several per minute]; p99 latency doubled.
Investigation
`dotnet-counters` showed Gen 2 growth; `dotnet-gcdump` showed millions of cached result objects in Gen 2.
Resolution
Replaced the cache with a bounded `MemoryCache` with size limits and cached compact keys instead of whole object graphs.
Prevention
Added Gen 2 GC rate to the service dashboard with an alert.

How to tell it (STAR)

In [our product API], a new cache made objects live just long enough to reach Gen 2. I used dotnet-counters and a gcdump to show the change in lifetimes, then bounded the cache and stored smaller entries. Gen 2 GCs went back to [a few per hour] and p99 recovered.

↩ Usually asked after

More in Garbage Collection

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me