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.
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 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:
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.
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.
new SomeClass() starts here · Fast, frequent, cheap collectionsNotice: 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.
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.
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.
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."
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.
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.
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:
_cache dictionary 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.RequestContext object is created fresh per call and becomes unreachable the moment HandleRequest returns — it's exactly the kind of short-lived object Gen0 exists to handle cheaply._cache itself never needs re-examining by anything except the rare, full Gen2 collection.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.
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.
fixed or GCHandle.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."
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.
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.
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.
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.
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.
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:
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?
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?
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?
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?
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?
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.
dotnetmadeeasy.com — Learn C# and .NET, the right way.