Skip to content
QSWEQB
hardConceptScenarioSeniorStaffLead

How does the .NET garbage collector decide what to collect, and how would you make a hot path allocate less?

The GC segregates the heap into generations on the assumption that most objects die young, collecting gen0 often and gen2 rarely, with large objects on a separate heap that is not compacted by default. Allocating less means removing boxing and using Span<T> over pooled or stack memory.

6 min readUpdated 2026-07-27

What the interviewer is scoring

  • Whether the generational hypothesis is stated as the reason for the design, not just the three generation names
  • Does the candidate know that the large object heap is collected with gen2 and left uncompacted unless asked otherwise
  • That boxing is described as an allocation plus a copy, with a concrete source they have hit in real code
  • Whether Span<T> is explained through its ref struct constraints rather than as a faster array
  • Does the candidate propose measuring before optimising, and name a tool that would show allocation rate

Answer

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:

using Microsoft.Extensions.Logging;

static class BoxingSources
{
    public static void Examples(List<int> numbers, ILogger logger, int count, long ms)
    {
        // 1. Interface dispatch on a value type: the int is boxed to satisfy IComparable.
        IComparable boxed = 42;

        // 2. The non-generic Equals overload boxes its argument.
        bool same = 42.Equals((object)7);

        // 3. Enumerating through the interface boxes List<T>'s struct enumerator,
        //    which existed precisely so that the concrete foreach allocates nothing.
        IEnumerable<int> asInterface = numbers;
        foreach (int n in asInterface) { }

        // 4. Any object[] parameter list, including structured logging arguments.
        logger.LogInformation("Processed {Count} in {Ms}ms", count, ms);
    }
}

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.

// Substring allocates a new string per call.
static (string, string) SplitAllocating(string kv)
{
    int i = kv.IndexOf('=');
    return (kv[..i], kv[(i + 1)..]);
}

// AsSpan + Slice allocates nothing; int.Parse has a ReadOnlySpan<char> overload,
// so the numeric case never materialises a string at all.
static int ValueOf(string kv)
{
    ReadOnlySpan<char> span = kv.AsSpan();
    return int.Parse(span[(span.IndexOf('=') + 1)..]);
}

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.

Likely follow-ups

  • Why does the GC need a write barrier, and what would break without one?
  • Your service shows steadily rising memory with no gen2 growth. Where do you look?
  • When is ArrayPool<T> the wrong answer?
  • Why can a Span<T> not be a field of a class or live across an await?

Related questions

Further reading

garbage-collectionmemoryboxingspanperformance