The generational hypothesis, Gen 0/1/2, promotion, card tables and write barriers, regions, and mark-sweep-compact.
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.
"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.
Empirically, across almost every kind of program:
The GC exploits this by collecting young objects often and old objects rarely.
| 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.
| 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.
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.
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.
On 64-bit, .NET 7 replaced the old few-big-segments design with regions: many fixed-size chunks, each owned by a generation.
| 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.
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].
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().
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.
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.
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.
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:
int or into locals do not.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)
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.
| 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.
| 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.
| 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 |
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.List<byte[]> that keeps every tenth allocation for 30 seconds. Watch Gen 2 count and heap size climb.GC.GetGCMemoryInfo(GCKind.Ephemeral) and GCKind.FullBlocking and compare PauseDurations.dotnet-trace collect --profile gc-verbose and open it in PerfView's GCStats view; find the trigger reason for each GC.[MemoryDiagnoser] that writes references into a long-lived array versus a local array; compare timings to see write barrier cost.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.
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.
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.
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.
GC.Collect() to helpWhat 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.
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).
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.
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".
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).
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.
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.
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.
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.
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.
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.
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.
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.
Last updated 31 Oct 2026.