.NET Generational GC: Gen 0, Gen 1, Gen 2 and the LOH
Most objects die young. .NET's GC is built entirely around exploiting that one observation.
In the last lesson, you learned the GC traces the object graph from roots to find garbage. But here's a question that should bother you: if a server has gigabytes of live objects on its heap, tracing all of it on every single collection would be brutally slow. A collection running every few milliseconds, each one walking gigabytes of memory, would make the pauses you just learned to worry about far worse, not better.
.NET doesn't do that. Instead, it leans on a decades-old, empirically-verified observation about how real programs actually allocate memory: the vast majority of objects are created, used briefly, and discarded almost immediately — a request DTO, a LINQ intermediate list, a string built for one log line. Very few objects stick around for the long haul. This single fact — the generational hypothesis — reshapes the entire design of the GC.
In this lesson, you'll learn how .NET divides the heap into generations to exploit this pattern, why that makes most collections extremely cheap, and how large and pinned objects get special treatment.
What Is It?
The Simple Explanation
Instead of treating the managed heap as one big undifferentiated pool, .NET splits it into generations — Gen0, Gen1, and Gen2 — based on how long an object has survived so far. New objects start in Gen0. If an object survives a collection (something still references it), it gets promoted to the next generation up. The GC collects the younger generations far more often than the older ones, because that's where almost all the reclaimable garbage actually is.
The Technical Definition
The generational hypothesis is the empirical observation, borne out across decades of measurement in managed-runtime research, that in most programs, the majority of allocated objects become unreachable very quickly, while a small minority live for a very long time (often the entire lifetime of the process). A generational GC is designed around this asymmetry:
Gen0 — the nursery
- Where every new small object is born
- Small, fits largely in CPU cache — collections are fast
- Collected very frequently — often thousands of times over a process's life
Gen1 — the buffer
- Objects that survived one Gen0 collection
- Acts as a middle ground between short-lived and truly long-lived objects
- Collected less often than Gen0, more often than Gen2
Gen2 — the long-term residents
- Objects that have survived multiple collections — caches, singletons, long-lived service state
- Collected rarely, but a Gen2 collection is the most expensive kind, since it also implies checking Gen0 and Gen1
LOH / POH — the special cases
- Large Object Heap: objects ≥ 85,000 bytes, collected alongside Gen2, not compacted by default
- Pinned Object Heap (.NET 5+): a dedicated home for objects pinned in place for interop
Why Does It Exist?
The Problem
Imagine a GC with no concept of generations, sweeping the entire heap on every collection. If your process has accumulated 4 GB of long-lived, genuinely-in-use data (caches, loaded configuration, connection pools) plus a constant churn of small short-lived request objects, every single collection — even one only trying to clean up a handful of recently-discarded DTOs — would have to trace through the entire 4 GB to be sure nothing was missed. That's an enormous amount of wasted work: you're re-verifying, over and over, that the same long-lived objects are still alive, when nothing about them has changed since the last time you checked.
The Solution
If most garbage is produced by young, short-lived objects, then a GC that focuses its effort almost entirely on the youngest generation will catch the overwhelming majority of reclaimable memory, cheaply and frequently — without ever needing to re-examine the large, stable, long-lived portion of the heap on every pass. Objects only get "promoted" into the more expensive-to-collect older generations once they've proven, by surviving a collection, that they're not part of that fast-churning majority. This turns collection cost from "proportional to total heap size" into something much closer to "proportional to how much was recently allocated" — a dramatic difference for any application with a large amount of stable, long-lived state.
Big Picture
- Every
new SomeClass()starts here · Fast, frequent, cheap collections
- Survived one Gen0 collection · Collected less frequently
- Survived multiple collections · Rare, expensive, full-heap-style collections
- Collected together with Gen2 · LOH not compacted by default · POH exists so pinning doesn't block compaction elsewhere
Notice: promotion is about surviving a collection, not about age in wall-clock time or object size (except for the LOH, which is size-based from birth, not promotion-based). An object created a millisecond before a Gen0 collection runs, that happens to still be referenced, gets promoted to Gen1 just as readily as one that's been sitting untouched for an hour.
How It Works
Step 1 — Every new small object is born in Gen0
var dto = new OrderRequestDto(); // allocated in Gen0, unconditionally
There's no way to request a different starting generation for an ordinary small object — Gen0 is where everything begins, whether it will end up living for a microsecond or for the life of the process.
Step 2 — Gen0 fills up, triggering a Gen0 collection
Gen0 has a relatively small budget. Once enough has been allocated into it, a Gen0 collection runs: trace from roots, find what's still reachable within Gen0 (and check references pointing into it from older generations, tracked cheaply — see Under the Hood), and reclaim the rest. Because Gen0 is small — often small enough to fit largely within the CPU's cache — this is fast, frequently completing in a fraction of a millisecond.
Step 3 — Survivors are promoted to Gen1
Any object still reachable after that Gen0 trace gets moved (as part of compaction) into the Gen1 region. This is the GC's way of saying: "you weren't part of the disposable majority — you might be here a while, so I'll check on you less often from now on."
Step 4 — The pattern repeats, one level up, much less often
Gen1 has its own (larger) budget. When it fills, a Gen1 collection runs — which, by necessity, also re-examines Gen0 (since Gen0 is "younger" than Gen1). Survivors of a Gen1 collection are promoted to Gen2. Gen2 collections are the rarest and most expensive, because a full Gen2 collection effectively examines the entire heap (aside from LOH nuances) — this is sometimes called a "full" collection.
Step 5 — Large objects skip the nursery entirely
var buffer = new byte[100_000]; // ≥ 85,000 bytes → allocated on the LOH directly
An object at or above the 85,000-byte threshold is allocated directly on the Large Object Heap, bypassing Gen0/Gen1 entirely. It's logically collected together with Gen2, and — importantly — the LOH is not compacted by default: moving very large blocks of memory around is itself expensive, so instead of relocating survivors, the LOH tracks and reuses freed gaps in place. This trades some potential memory fragmentation for avoiding the cost of physically copying large blocks on every collection.
Simple Example
public class RequestCache
{
// Lives for the entire process — will end up in Gen2 quickly
// and stay there, since it's referenced by a static field forever.
private static readonly Dictionary<string, string> _cache = new();
public static string HandleRequest(string requestId)
{
// Short-lived — created, used, discarded within this one call.
// Very likely reclaimed at the very next Gen0 collection.
var context = new RequestContext(requestId, DateTime.UtcNow);
if (_cache.TryGetValue(requestId, out var cached))
return cached;
var result = $"Processed {requestId} at {context.Timestamp}";
_cache[requestId] = result;
return result;
}
}
Code → What happens generationally:
- The static
_cachedictionary is rooted for the life of the process — it survives every collection it's ever part of, and quickly ends up promoted all the way to Gen2, where it stays. - Each
RequestContextobject is created fresh per call and becomes unreachable the momentHandleRequestreturns — it's exactly the kind of short-lived object Gen0 exists to handle cheaply. - Under sustained load, this method produces a steady stream of Gen0 garbage while
_cacheitself never needs re-examining by anything except the rare, full Gen2 collection.
Real-World Example
Consider an ASP.NET Core API that has an in-memory IMemoryCache holding product catalog data (long-lived, ends up in Gen2), a connection pool (also long-lived), and, per request, deserializes a JSON body into DTOs, builds LINQ intermediate collections, and constructs a response object (all short-lived, Gen0-only in the common case).
Under load, this API might perform hundreds of Gen0 collections per second — each one sub-millisecond, essentially invisible in latency graphs — while Gen2 collections (which would need to examine the catalog cache and connection pool) might happen only a handful of times per hour, or less. This is the generational hypothesis paying off directly: because the vast majority of collections only need to look at a small, fast-churning slice of the heap, the API sustains high throughput without frequent, expensive full-heap sweeps.
If that same API also processes uploaded files as in-memory byte arrays over 85,000 bytes, those buffers land on the LOH — and if the application allocates and discards many such large buffers repeatedly, it can accumulate LOH fragmentation over time, since the LOH isn't compacted by default. This is one reason streaming large payloads (rather than buffering them whole in memory) is a common recommendation for high-throughput services handling large files.
Analogy
Imagine an office that checks its main inbox tray dozens of times a day (Gen0) — most of what lands there is junk mail, tossed within minutes of arriving. Anything that turns out to need a reply or follow-up gets moved to a "pending" folder (Gen1), checked less often — maybe once an hour. And anything that survives even that — a signed, permanent contract — gets filed away in the archive room (Gen2), checked only during rare, thorough audits.
Nobody re-reads the entire archive room every time a new piece of junk mail arrives in the main tray — that would be absurd. The office only re-examines a level once that level's own tray fills up, and only promotes something to the next level once it's proven, by not being thrown away, that it's worth tracking more carefully.
Oversized packages (the Large Object Heap) get put straight into a separate loading dock, since carrying them around every time something gets reorganized would be impractical — they're checked on the same audit schedule as the archive room, but they aren't physically shuffled around the way smaller items are.
Under the Hood
- A Gen0 collection needs to know about any Gen2 object that holds a reference into Gen0 (this does happen — e.g., a long-lived cache holding a reference to a newly-created object).
- Rather than re-scanning all of Gen2 on every Gen0 collection (which would defeat the entire purpose), the runtime maintains a lightweight "card table" — a bitmap-like structure updated cheaply via a write barrier whenever a reference field in an older generation is modified.
- This lets a Gen0 collection cheaply know exactly which small regions of older generations might need checking, instead of scanning them in full.
- Workstation GC — optimized for low-latency, single-application, client-style scenarios (a desktop app running alongside other processes competing for CPU). Uses fewer, or sometimes one, GC heap and thread.
- Server GC — optimized for throughput on multi-core server workloads. Creates one heap and one dedicated GC thread per logical CPU core, allowing collections to happen in parallel across cores, and typically uses larger generation budgets.
- Server GC is the default for most ASP.NET Core deployments (multi-core server environment); Workstation GC is generally the right choice for a single-user desktop or CLI application, where you don't want the GC competing aggressively with other processes for every core.
- Both modes support concurrent (background) collection for the older generation, layered on top of this same generational design.
- Interop scenarios sometimes need an object's memory address to stay fixed (unmovable) for a period — for example, passing a managed buffer to native code via
fixedorGCHandle. - Before the POH existed, a pinned object sitting in Gen0/Gen1/Gen2 would block compaction around it, since the GC can't move a pinned object, which could fragment those generations.
- The POH gives pinned objects a dedicated home, collected alongside Gen2, so pinning doesn't interfere with compaction of the ordinary generations.
Common Confusion
1. "Generations are based on how long an object has existed, in wall-clock time"
Promotion is based on surviving collections, not elapsed time. An object created a fraction of a second before a Gen0 collection, that happens to still be referenced, is promoted to Gen1 — while an object that's technically "older" in wall-clock time but was already garbage before the collection ran is simply reclaimed, never promoted at all. Age, here, means "number of collections survived," not "seconds alive."
2. "The Large Object Heap is just 'Gen3'"
It's a related but distinct concept. The LOH is defined by object size (≥ 85,000 bytes) at the moment of allocation, not by survival through generational promotion — a large object goes straight to the LOH on its very first allocation, it never starts in Gen0. It's collected on the same schedule as Gen2 (so it's sometimes loosely grouped with it), but it has its own allocation rules and its own default non-compacting behavior.
3. "Server GC is always faster than Workstation GC"
Server GC generally wins on raw throughput for a multi-core machine dedicated to running your application — but it does so by claiming more memory (per-core heaps) and more CPU (per-core GC threads), which can be the wrong trade-off on a machine also running other important processes, or in a memory-constrained container. "Faster" depends entirely on the deployment context; neither mode is universally correct.
Common Mistakes
Mistake 1 — Ignoring accidental large-object allocations
Repeatedly allocating and discarding buffers or collections that happen to cross the 85,000-byte threshold (common with byte arrays, large strings, or big generic collections of value types) without realizing they bypass Gen0 and land on the non-compacted LOH.
For workloads that create many large, short-lived buffers, consider array pooling (ArrayPool<T>) to reuse buffers instead of repeatedly allocating and discarding them — this is a deeper performance topic, but recognizing the LOH threshold is the first step toward noticing the problem exists.
Mistake 2 — Keeping short-lived-looking data in a long-lived container
Storing per-request or per-operation objects in a static collection "just in case," accidentally forcing objects that should be Gen0 garbage to survive into Gen2 and stay rooted forever.
Be deliberate about what actually needs to be long-lived (configuration, caches with eviction, connection pools) versus what's naturally short-lived (request data, intermediate computation results) — and don't accidentally root the latter through the former.
Mistake 3 — Choosing Server GC or Workstation GC without considering the deployment environment
Leaving the default GC mode unexamined in a memory-constrained container, where Server GC's per-core heaps might claim more memory than the container's limit comfortably allows.
Understand which mode your hosting environment defaults to, and that it's configurable — a decision worth revisiting deliberately for constrained or unusual deployment shapes, rather than assuming the default is always right.
Why Does This Matter for My Code?
You don't choose generations directly — the GC manages promotion automatically. But this design has direct, practical implications for how you write and reason about allocation-heavy code:
- Short-lived objects are genuinely cheap to allocate and collect in .NET — this is exactly why idiomatic C# doesn't discourage creating small, short-lived objects the way some other, non-generational-GC environments do.
- Accidentally keeping short-lived-looking data reachable for a long time (through a static collection, a long-lived event subscription) pushes it into Gen2, where collection is far more expensive — this is a real, measurable performance cost, not just a style concern.
- Large allocations (≥ 85,000 bytes) behave differently enough — no compaction by default — that repeated large-buffer churn is worth watching for in profiling, especially in high-throughput file- or image-processing services.
- Server vs. Workstation GC is a real deployment decision, not a default to leave unexamined, especially in containerized or memory-constrained environments.
Mental Model
Gen1 = a buffer between short-lived and truly long-lived.
Gen2 = long-term residents — rare, expensive, full-heap-style collections.
LOH = size-based (≥ 85,000 bytes), not compacted by default.
POH = a dedicated home for pinned objects, so pinning doesn't block compaction elsewhere.
Remember: promotion means "you survived a collection," not "you've existed a while" — the GC is betting, generation by generation, that most things it's never had to check twice are still garbage next time too.
Key Takeaway
- The generational hypothesis — most objects die young — is why .NET splits the heap into Gen0, Gen1, Gen2, collecting younger generations far more often, and much more cheaply, than older ones.
- Objects are promoted to the next generation by surviving a collection, not by wall-clock age.
- The Large Object Heap (≥ 85,000 bytes) holds large allocations directly, collected with Gen2, and is not compacted by default — moving huge blocks is expensive, so freed gaps are reused in place instead.
- The Pinned Object Heap (.NET 5+) gives interop-pinned objects a dedicated home so they don't block compaction of the ordinary generations.
- Server GC (per-core heaps and threads, throughput-optimized) is the typical default for multi-core server workloads; Workstation GC suits single-user, latency-sensitive client scenarios — the right choice depends on deployment context.
Check Your Understanding
You've learned how generational GC exploits the fact that most objects die young. Let's confirm the mechanics.
1. What determines whether an object is promoted from Gen0 to Gen1?
Show answer
Correct: B
Why B is correct: Promotion happens because an object survived a collection — it was still reachable from a root when the trace ran — regardless of how much wall-clock time had elapsed since it was created.
Why A is incorrect: Elapsed time plays no role — an object created moments before a collection can be promoted just as readily as one that existed longer, as long as it survives the trace.
Why C is incorrect: IDisposable is about explicit resource cleanup, an entirely separate concern from GC generation promotion.
Why D is incorrect: Object size determines LOH placement, a separate mechanism from Gen0→Gen1 promotion, which applies to ordinary (smaller) objects.
Reinforcement: "Survival, not age" is the key distinction — this is exactly the common misconception the lesson called out directly.
2. Why are Gen0 collections typically so much faster than Gen2 collections?
Show answer
Correct: B
Why B is correct: Gen0 is deliberately kept small, and because most of what's in it is short-lived garbage, a collection there examines a small slice of the heap and reclaims a large share of it. A Gen2 collection, by contrast, effectively has to examine the entire heap (aside from LOH specifics), making it inherently more expensive.
Why A is incorrect: Gen0 collections still perform a real trace from roots to determine what's reachable — they don't skip verification, they just have far less to verify.
Why C is incorrect: There's no dedicated "Gen0-only" core reserved by generation — Server GC's per-core threads apply to GC work generally, not specifically to Gen0.
Why D is incorrect: Object byte size isn't what determines generation membership for ordinary objects — survival through collections is. Size only directly determines LOH placement, a separate mechanism.
Reinforcement: Collection cost scales with how much of the heap needs to be examined — and generational GC's entire design goal is to keep that "examined" portion small on the vast majority of collections.
3. A service allocates a 200,000-byte byte[] buffer for every incoming file upload, uses it briefly, then discards it. Which statement about this allocation is accurate?
Show answer
Correct: B
Why B is correct: At 200,000 bytes, this buffer exceeds the 85,000-byte LOH threshold, so it's allocated directly on the Large Object Heap rather than starting in Gen0. The LOH is collected alongside Gen2 and is not compacted by default.
Why A is incorrect: Only objects below the LOH threshold start in Gen0 and go through generational promotion — this buffer bypasses that path entirely from its very first allocation.
Why C is incorrect: There's no such splitting mechanism — an object is allocated as a single unit in one location (LOH, in this case), never divided across generations.
Why D is incorrect: 200,000 bytes is a perfectly ordinary allocation size for .NET; it's well within normal limits — it simply crosses the specific threshold that routes it to the LOH instead of Gen0.
Reinforcement: This is exactly the kind of pattern flagged in Common Mistakes — repeated large-buffer churn like this is worth watching for, since the LOH's default non-compacting behavior can lead to fragmentation over time.
4. A team is deploying a .NET API to a multi-core Linux server dedicated entirely to running that one application, with plenty of available memory. Which GC mode is most appropriate, and why?
Show answer
Correct: B
Why B is correct: Server GC is designed exactly for this scenario — a multi-core machine dedicated to running the application, where trading additional memory and CPU for higher throughput via parallel, per-core collection is the right trade-off.
Why A is incorrect: Workstation GC is not universally faster — it's optimized for single-user, latency-sensitive, resource-sharing scenarios, which is the opposite of a dedicated multi-core server with memory to spare.
Why C is incorrect: .NET's generational GC (in either Server or Workstation mode) runs identically across Windows, Linux, and macOS — there's no separate GC algorithm required for Linux.
Why D is incorrect: The two modes have genuinely different performance characteristics and resource trade-offs, as covered under the hood — the choice is a real, deployment-specific decision.
Reinforcement: GC mode selection should match the deployment shape — dedicated multi-core throughput-oriented servers favor Server GC; shared, resource-constrained, or single-user scenarios favor Workstation GC.
5. How does the GC avoid re-scanning the entire Gen2 heap every time it needs to check whether a Gen2 object references something in Gen0?
Show answer
Correct: B
Why B is correct: Card tables, kept up to date cheaply via write barriers on reference field assignment, let a Gen0 collection cheaply identify the small handful of older-generation regions that might reference something in Gen0 — without ever needing a full re-scan of the older generations.
Why A is incorrect: The GC does correctly account for these cross-generation references — ignoring them would risk incorrectly reclaiming an object still referenced from an older generation, which would be a serious correctness bug, not an accepted trade-off.
Why C is incorrect: This tracking is entirely automatic and internal to the runtime — there's no C# API developers call to inform the GC about cross-generation references.
Why D is incorrect: Gen0 collections continue to run normally alongside Gen2 objects existing — the card table mechanism is precisely what makes that coexistence efficient, not a reason to disable Gen0 collection.
Reinforcement: Card tables are the piece of engineering that makes generational GC's core promise possible — without them, "only look at Gen0" would be unsafe, since Gen0 could be referenced from anywhere in the older generations.
You now understand exactly why generational GC makes short-lived allocation cheap in .NET — next, you'll see how allocation itself actually happens on the heap.