📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What happens during a Gen 0 allocation and collection in .NET, and how do you reduce Gen 0 GC frequency?

Allocation contexts and bump-pointer allocation, the Gen 0 budget, what triggers a Gen 0 GC, allocation rate metrics and reducing allocations.

Mid-Level Theory Advanced allocation gc gen0 performance

🎯 Key points

  • Small objects are allocated by bumping a pointer in a per-thread allocation context
  • A Gen 0 GC runs when allocations exceed the dynamically tuned Gen 0 budget
  • Gen 0 GCs promote survivors to Gen 1 and are cheap when few objects survive
  • High allocation rate means frequent pauses and CPU spent in GC
  • Measure with dotnet-counters, allocation tracking and MemoryDiagnoser
  • Reduce allocations with spans, pooling, fewer closures and LINQ in hot paths, and avoiding boxing

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"Allocating a small object in .NET is a pointer bump inside a per-thread allocation context, so new itself is very cheap. The cost shows up later: once the total bytes allocated since the last GC exceed the dynamically tuned Gen 0 budget, the runtime suspends threads and runs a Gen 0 GC. That GC marks the few survivors, promotes them to Gen 1 and makes the rest of Gen 0 reusable. Each one is short, but a high allocation rate means many of them, so CPU and tail latency suffer. To reduce Gen 0 frequency I don't tune the GC first; I measure who allocates and remove allocations from hot paths."


1. The allocation fast path

Step What happens
1 new Order() compiles to IL newobj; the JIT calls an allocation helper
2 Helper checks the thread's allocation context: alloc_ptr + size <= alloc_limit?
3 Yes → bump alloc_ptr, write the MethodTable pointer, return. Memory was pre-zeroed. No lock.
4 No → slow path: ask the GC for a new chunk of Gen 0 (an allocation quantum, roughly a few KB)
5 If the Gen 0 budget for this cycle is used up, the slow path triggers a Gen 0 GC first

Objects of 85,000 bytes or more skip Gen 0 entirely and go to the LOH.

2. What triggers a Gen 0 GC

Trigger Notes
Gen 0 budget exceeded By far the most common
GC.Collect(0) Tests and demos only
Higher-generation GC Gen 1 and Gen 2 GCs always include Gen 0
Memory load / hard limit pressure GC becomes more aggressive near limits

The budget is not a fixed number. The GC sizes it from hardware (cache sizes), GC mode (Server GC uses larger, per-heap budgets) and recent survival rates: if lots of Gen 0 survives, it adjusts. With DATAS in .NET 9+, the number of Server GC heaps (and so total Gen 0 space) also adapts to load.

3. What a Gen 0 GC does

  1. Suspend all managed threads (ephemeral GCs are always blocking, never background).
  2. Mark from roots: stacks, registers, statics, handles, finaliser queue, plus dirty cards in Gen 1/Gen 2.
  3. Compact survivors (or promote whole regions) into Gen 1.
  4. Reset Gen 0 so allocation contexts are contiguous again.
  5. Resume threads.

Pause time scales with survivors and roots, not with garbage. A Gen 0 GC with 1% survival on a typical service is usually well under a millisecond to a few milliseconds.

4. Why reducing Gen 0 frequency still matters

Symptom Cause
High % time in GC Many GCs × fixed cost of suspension and root scanning
p99 latency spikes Requests unlucky enough to be paused by several GCs
More objects reaching Gen 1/2 More frequent GCs catch in-flight request objects that are still alive, so they get promoted
Memory bandwidth Zeroing and copying

The third row is the one people miss: allocating faster shortens the time between GCs, so more "short-lived" objects are still alive when a GC happens and get promoted.

5. Measuring

Tool Metric
dotnet-counters monitor System.Runtime Allocation rate, Gen 0/1/2 counts, % time in GC
GC.GetAllocatedBytesForCurrentThread() Bytes allocated by an operation on this thread
GC.GetTotalAllocatedBytes() Process-wide allocations
BenchmarkDotNet [MemoryDiagnoser] Allocated bytes and Gen 0 collections per 1,000 ops
dotnet-trace / PerfView / VS allocation tracking Which types and call stacks allocate

6. Reducing allocations

Allocation source Fix
Temporary buffers stackalloc (small, bounded), ArrayPool<T>.Shared
Substring, Split ReadOnlySpan<char>, MemoryExtensions.Split into Span<Range>
LINQ in hot loops Plain for/foreach loops
Boxing Generics, avoid object/non-generic interfaces, avoid struct via interface
Closures capturing locals static lambdas with explicit state; avoid lambdas per call
String formatting Interpolated string handlers, TryFormat into spans, string.Create
List<T> growth Set capacity up front
Async methods that usually complete synchronously ValueTask where measured, cached completed tasks
Serialisation via intermediate objects Utf8JsonReader/Utf8JsonWriter, source-generated System.Text.Json

7. When not to fight it

If % time in GC is low, latency targets are met and Gen 2 is quiet, a high Gen 0 rate is fine. Allocation-reduction code is harder to read; I apply it only where profiles show the hot path.

Production example

Our [telemetry ingestion endpoint] took [hundreds of thousands] of JSON events per second. dotnet-counters showed an allocation rate of [about 2 GB/s] and [about 200] Gen 0 GCs per second, with CPU close to saturation. An allocation trace showed intermediate DTOs, strings and List<T>s created per event and then mapped to our storage format. I rewrote the hot path to parse with Utf8JsonReader over pooled buffers straight into the storage structs. Allocations fell by [about 85%], Gen 0 GCs dropped to [about 30] per second, and CPU fell by [about a third]. I added a BenchmarkDotNet test with an allocation budget so the regression couldn't come back unnoticed.

🔍 What the interviewer is checking

The interviewer checks you understand allocation mechanics and how to reduce GC frequency in practice.

Green flags: allocation contexts and bump pointer, Gen 0 budget triggers GC, cost proportional to survivors, allocation rate metrics, concrete reduction techniques, measured approach.

Red flags: thinks new is slow like malloc; tunes GC knobs first; pools everything.

📚 Learn in depth

1. Allocation contexts in memory

Thread A context          Thread B context          unused Gen 0 budget
+--------------------+    +--------------------+    +----------------------+
|obj|obj|  free     |    |obj| free           |    |                      |
+--------------------+    +--------------------+    +----------------------+
        ^ptr      ^limit       ^ptr       ^limit
  • Each thread bumps its own pointer, so there is no contention on the fast path.
  • With Server GC, each heap has its own Gen 0 and threads are affinitised to heaps; the allocator balances across heaps when one becomes busy.
  • Before handing out a context, the GC zeroes the memory. That is why new T() fields start at default without per-object work.

2. From C# to the allocation helper

var o = new Order(42);

IL:

ldc.i4.s   42
newobj     instance void Order::.ctor(int32)
stloc.0

The JIT turns newobj into a call to an allocation helper chosen per type (for example a fast helper for small, non-finalisable objects) followed by the constructor call. Types with finalisers use a slower helper because the object must also be registered on the finalisation queue, which is one reason finalisers make allocation more expensive.

The JIT can also eliminate allocations: since .NET 9/10 it performs limited object stack allocation (escape analysis) for some small objects that provably do not escape a method, and boxing in some patterns is removed. Do not design around it, but it explains why micro-benchmarks sometimes show zero bytes where you expected an allocation.

3. Hidden allocations in ordinary C#

Code Hidden allocation
foreach (var x in (IEnumerable<int>)list) Boxed enumerator (struct enumerator via interface)
string.Format("{0}", 42) (old APIs) Boxing of 42, params object[]
items.Where(x => x > limit) Closure object (captures limit), delegate, iterator
async Task<int> GetAsync() that awaits something incomplete State machine box on the heap
params int[] values Array per call (C# 13 params ReadOnlySpan<T> avoids it)
enumValue.ToString(), HasFlag in old runtimes Strings / boxing
list.ToArray() / ToList() "just in case" Copy of the whole collection
$"..." passed to an ILogger.LogDebug with logging disabled String built even though it's discarded (use templates or LoggerMessage)

4. The Gen 0 budget and promotion rate

The GC tracks survival rate per generation. If Gen 0 survival is high, collections are expensive and the GC may grow the budget; if low, collections are cheap and frequent.

What matters for you:

objects promoted per second ≈ allocation rate × Gen 0 survival rate

Cut either and Gen 1/Gen 2 work drops. Allocation rate is the one you can usually control directly.

5. Pinning in Gen 0

If a Gen 0 object is pinned (fixed, a pinned GCHandle, an in-flight native I/O buffer) during a GC, the GC cannot move it. It may leave it in place (sometimes demoting it so it stays in Gen 0), which fragments Gen 0 and makes allocation contexts smaller. Long-lived pins of fresh buffers in hot paths are a classic cause of Gen 0 fragmentation; use the POH or pooled buffers instead.

6. GC.AllocateUninitializedArray and [SkipLocalsInit]

  • GC.AllocateUninitializedArray<T>(n) can skip zeroing (worthwhile for large arrays you fully overwrite).
  • [SkipLocalsInit] only affects stack memory (stackalloc, locals); it does nothing for heap allocations.

7. Measuring allocations precisely

[MemoryDiagnoser]
public class ParseBench
{
    private const string Line = "2026-10-23,orders,42,19.95";

    [Benchmark(Baseline = true)]
    public int Split() => int.Parse(Line.Split(',')[2]);

    [Benchmark]
    public int Span()
    {
        ReadOnlySpan<char> s = Line;
        Span<Range> r = stackalloc Range[4];
        s.Split(r, ',');
        return int.Parse(s[r[2]]);
    }
}

The Allocated column is deterministic and much more stable than timing, which makes it a good regression test.

8. Version notes

Version Relevant change
.NET Core 2.1+ Span<T>, ArrayPool<T> widely used in BCL
.NET 5 GC.AllocateUninitializedArray, POH
.NET 6 Interpolated string handlers (C# 10) cut formatting allocations
.NET 8 MemoryExtensions.Split into Span<Range>, frozen collections, more allocation-free BCL APIs
.NET 9 DATAS default for Server GC; params spans (C# 13); JIT object stack allocation begins
.NET 10 JIT escape analysis extended to more cases (e.g. some delegates and small arrays)

9. Try it yourself

  1. Run the code example; record bytes/op and Gen 0 GCs for both parsers.
  2. Put the two parsers into BenchmarkDotNet with [MemoryDiagnoser] and compare Allocated and Gen0 columns.
  3. Add a foreach over a List<int> cast to IEnumerable<int> in a loop and measure the enumerator boxing.
  4. Run a small web API under load and watch dotnet-counters allocation rate; then add a ToList() in the hot path and watch it rise.
  5. Capture dotnet-trace collect --profile gc-verbose, open it in PerfView, and use the "GC Heap Alloc Ignore Free" view to find top allocating stacks.
  6. Compare GC.CollectionCount(0) for the same workload under Workstation and Server GC (<ServerGarbageCollector>).

💻 Code

const string line = "2026-10-23,orders,42,19.95";

Measure("Split + parse", () =>
{
    string[] parts = line.Split(',');          // allocates an array and four strings
    return int.Parse(parts[2]);
});

Measure("Span parse", () =>
{
    ReadOnlySpan<char> span = line;
    Span<Range> ranges = stackalloc Range[4];
    span.Split(ranges, ',');                   // no heap allocation
    return int.Parse(span[ranges[2]]);
});

static void Measure(string name, Func<int> parse)
{
    parse(); // warm up
    int gen0 = GC.CollectionCount(0);
    long before = GC.GetAllocatedBytesForCurrentThread();

    long sum = 0;
    for (int i = 0; i < 1_000_000; i++) sum += parse();

    long bytes = GC.GetAllocatedBytesForCurrentThread() - before;
    Console.WriteLine($"{name,-14} allocated {bytes / 1_000_000.0:N1} bytes/op, Gen 0 GCs: {GC.CollectionCount(0) - gen0}, sum {sum}");
}

⚠ Common mistakes

1. Believing new is expensive like malloc

What happens: developers avoid creating small objects at all costs and write convoluted code. Why it is wrong: the fast path is a pointer bump. The real cost is the GC work caused by a high allocation rate. Do instead: measure allocation rate and GC time; optimise only hot paths.

2. Tuning GCgen0size to get fewer GCs

What happens: someone raises the Gen 0 size in config to cut GC counts. Why it is wrong: the GC already adapts the budget; a forced large Gen 0 increases pause time, memory and cache misses. Do instead: reduce allocations; leave the budget to the runtime unless a measured, specific case justifies it.

3. Pooling everything

What happens: every object comes from a custom pool. Why it is wrong: pooled objects become long-lived (Gen 2), old-to-young references dirty cards, and use-after-return bugs appear. Do instead: pool large or very frequently allocated buffers (ArrayPool<T>, ObjectPool<T> for expensive objects) after measuring.

4. Forgetting to return pooled arrays, or returning them twice

What happens: ArrayPool.Rent without Return in a finally, or double Return. Why it is wrong: missing returns just fall back to normal allocation (lost benefit); double returns corrupt data across requests. Do instead: try/finally around rent/return; never keep references after returning.

5. Assuming rented arrays are the exact size

What happens: code uses buffer.Length after ArrayPool.Rent(100). Why it is wrong: the pool may return a larger array. Do instead: track the requested length and slice: buffer.AsSpan(0, length).

6. LINQ and closures in the hottest loops

What happens: Where/Select/Any with captured variables run per item per request. Why it is wrong: each call allocates delegates, closures and iterators. Do instead: plain loops in hot paths; keep LINQ elsewhere for readability.

7. Hidden boxing

What happens: structs passed as object or interfaces, foreach over an interface-typed collection, old string.Format overloads. Why it is wrong: every box is a Gen 0 allocation. Do instead: generic APIs with constraints, concrete collection types, interpolated strings.

8. Building log messages that are never written

What happens: _logger.LogDebug($"Processed {order}") with Debug disabled. Why it is wrong: the string is still built (and order.ToString() called). Do instead: message templates, LoggerMessage source generator, or IsEnabled checks.

9. Judging by one micro-benchmark timing

What happens: "version B is 3 ns faster" drives a rewrite. Why it is wrong: timing noise is high, and allocations matter more under real concurrency. Do instead: compare Allocated per op and production allocation rate, then latency percentiles.

10. Using stackalloc without bounds

What happens: stackalloc byte[length] with user-controlled length. Why it is wrong: a large value causes a StackOverflowException that kills the process. Do instead: length <= 256 ? stackalloc byte[length] : ArrayPool<byte>.Shared.Rent(length) with a bounded threshold.

🏛 Senior insight: trade-offs

Allocation rate is one of the best single performance metrics for .NET services.

Allocation rate Typical action
Low and stable Leave it alone
High but GC time under ~5% Probably fine; check latency percentiles
High with rising Gen 1/Gen 2 counts Find what survives; lifetime problem
Very high in one endpoint Profile allocations there; spans, pooling, streaming parsers

Seniors reduce allocations where profiles point, not everywhere, and keep the code readable elsewhere.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate refers to promotion of survivors Why: Checks the generational model
    How to answer

    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.

  2. When: Candidate recommends pooling buffers Why: Probes ArrayPool usage
    How to answer

    First I measure: dotnet-counters for allocation rate and LOH size, then an allocation profiler to see which arrays dominate. Short-lived large arrays are the worst case, because anything of 85,000 bytes or more lands on the Large Object Heap, which is only collected in gen 2. The fixes are to reuse buffers with ArrayPool<T>.Shared (always return them, use only the requested length, clear sensitive data), use spans instead of copying slices, and stream data instead of building giant arrays. For speed I keep data in contiguous arrays of structs for cache locality and use the vectorised CopyTo/Fill/TensorPrimitives APIs instead of hand-written loops. Then I prove it with BenchmarkDotNet and compare production counters.

  3. When: Candidate uses spans to avoid allocations Why: Checks Span knowledge
    How to answer

    Span<T> is a ref struct holding a reference and a length: a typed, bounds-checked view over contiguous memory, whether that memory is an array, a stackalloc buffer, native memory or part of a string (through ReadOnlySpan<char>). Slicing (span[2..6], Slice) creates a new view without copying, so parsers and formatters can work on pieces of a buffer with no allocations. Because it can point at the stack, it is stack-only: it cannot be a field of a class, be boxed, be captured by a lambda, or live across await/yield (C# 13 allows span locals in async and iterator methods as long as they do not cross those points). Helpers in MemoryExtensions (IndexOf, SequenceEqual, Sort, Trim) are vectorised. CollectionsMarshal.AsSpan(list) exposes a list's backing array, but adding to the list invalidates it.

🛠 Real-world problems: how you handle them

Telemetry ingestion allocated 2 GB/s and spent a third of CPU on GC
Situation
An ingestion endpoint received [hundreds of thousands] of JSON events per second.
Signals
Allocation rate [about 2 GB/s], [about 200] Gen 0 GCs per second, CPU near saturation.
Investigation
Allocation profiling showed intermediate DTOs, strings and lists created per event before mapping to the storage format.
Resolution
Parsed directly with `Utf8JsonReader` over pooled buffers into the storage structs.
Prevention
Added a BenchmarkDotNet test with `[MemoryDiagnoser]` and an allocation budget per event.

How to tell it (STAR)

Our [telemetry endpoint] allocated [about 2 GB/s]. I traced it to intermediate objects per event and switched to Utf8JsonReader over pooled buffers. Allocations fell [85%] and CPU dropped by [a third].

↩ Usually asked after

More in Garbage Collection

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me