Generations exist because of one empirical claim
Allocation in .NET is cheap: the runtime hands each thread an allocation context and
moves a pointer forward, so creating an object is close to a pointer bump. The
expensive part is finding out which objects are dead, and that cost is what the
generational design attacks.
The claim it rests on — the generational hypothesis — is that most objects die very
young and the ones that survive tend to survive for a long time. If that holds, then
scanning only the newest objects will reclaim most of the garbage for a fraction of
the work. So the runtime segregates the heap:
- Gen0 holds newly allocated small objects. It is collected frequently, and a
gen0 collection only has to trace roots into gen0, which is a small region.
Surviving objects are promoted to gen1.
- Gen1 is a buffer between the two extremes, giving objects that survived one
collection a second chance to die before the runtime commits to treating them as
long-lived. Survivors are promoted to gen2.
- Gen2 holds everything long-lived. A gen2 collection traces the whole heap, so
it is the expensive one, and how often it happens is what determines whether your
latency profile has visible pauses.
Gen0 and gen1 are collected by copying survivors and compacting, which keeps
allocation contiguous and cache-friendly. Because objects move, the runtime needs a
write barrier: when code stores a reference into an older object, that store is
recorded in a card table, so a gen0 collection can find references from gen2 into
gen0 without scanning gen2. This is the mechanism that makes cheap young collections
possible at all.
The large object heap behaves differently
Objects of 85,000 bytes or more are allocated on the large object heap instead,
because copying them during compaction would cost more than the fragmentation it
avoids. Two consequences follow, and both matter in production.
First, the LOH is collected as part of a gen2 collection. A short-lived 100 KB buffer
is therefore not short-lived from the collector's point of view: it survives until the
next expensive collection, whenever that is. A request handler that allocates one
large array per request is effectively driving gen2 activity from the front door.
Second, the LOH is swept rather than compacted by default, so it fragments. Free
space accumulates in pieces too small for the next request and the process's memory
footprint grows while the live set does not. You can ask for a one-off compaction by
setting GCSettings.LargeObjectHeapCompactionMode to CompactOnce before inducing a
collection, but that is a heavy hammer and the better fix is to stop allocating large
short-lived buffers — which is what ArrayPool<T>.Shared exists for.
Note that the threshold is on the size of the individual allocation. A List<T> of a
million small objects is not on the LOH, but its internal array eventually is; and a
double[10001] crosses it while a double[10000] does not.
Boxing is an allocation you did not write
Boxing happens when a value type is converted to object, to dynamic, or to an
interface type. The runtime allocates a heap object, copies the value into it, and
hands back a reference. Unboxing copies back out. So every box is one allocation, one
copy, and one extra indirection on every subsequent read — and it is invisible in the
source, which is what makes it worth asking about.
The realistic sources are mundane:
The third one is the least obvious and the most instructive: List<int> exposes a
struct enumerator specifically so that foreach over the concrete type allocates
nothing, and typing the variable as IEnumerable<int> throws that away. The same
reasoning explains why EqualityComparer<T>.Default is preferred over
object.Equals in generic code, and why a struct used as a dictionary key should
override Equals(T) and GetHashCode rather than relying on the default
implementations.
Span<T> removes the allocation rather than pooling it
Span<T> is a ref struct holding a reference to contiguous memory and a length. It
can point at a managed array, at a slice of one, at stack memory from stackalloc, or
at unmanaged memory. Slicing a span creates a new view over the same memory rather
than copying, which is what makes it useful: parsing and reshaping work becomes a
sequence of views rather than a sequence of substrings and arrays.
static (string, string) SplitAllocating(string kv)
{
int i = kv.IndexOf('=');
return (kv[..i], kv[(i + )..]);
}
{
ReadOnlySpan<> span = kv.AsSpan();
.Parse(span[(span.IndexOf() + )..]);
}
The constraints are the interesting part, because they are what make it safe. Being a
ref struct, a Span<T> cannot be boxed, cannot be a field of a class, cannot be
captured by a lambda, and cannot live across an await or a yield. All of those
would place a reference to possibly-stack memory somewhere that outlives the stack
frame. When you need the same idea in an asynchronous method, you use Memory<T>,
which is a normal struct that can be stored and awaited across, and you convert to a
span at the point of use.
For buffers you cannot avoid, ArrayPool<T>.Shared rents an array and returns it,
which turns a repeated allocation into a reuse. Renting obliges you to return in a
finally, and to remember that a rented array may be larger than requested and will
contain whatever the previous tenant left there.
The part that separates a real answer
Everything above is a set of techniques, and reciting techniques is not what the question
tests. What an interviewer is listening for is whether you would find out where the cost
is before removing it.
Measure the allocation rate and the collection counts rather than auditing code by eye.
dotnet-counters shows allocation rate and gen0, gen1 and gen2 collection counts on a
live process; dotnet-gcdump and dotnet-trace show what is on the heap and who
allocated it; BenchmarkDotNet with the memory diagnoser reports bytes allocated per
operation for a specific method, which is the number to put in a pull request. Saying "I
would show allocations before and after under BenchmarkDotNet" answers better than
listing five allocation-free idioms, because most hot paths are slow for reasons that
have nothing to do with the GC, and de-allocating code that was waiting on a database
achieves nothing.
Two diagnoses are worth separating. Rising memory with a flat live set is usually
fragmentation or pooling gone wrong. Rising memory with a rising live set is a real leak,
and in .NET it is almost always an event handler, a static collection, or a cache with no
eviction policy holding references the collector is obliged to honour.
Generations are a bet that young objects die; the LOH is where that bet stops
paying. Boxing and large short-lived buffers are the two ways ordinary code
undermines the collector, and a profiler is what tells you which of them you have.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).