Allocation contexts and bump-pointer allocation, the Gen 0 budget, what triggers a Gen 0 GC, allocation rate metrics and reducing allocations.
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.
"Allocating a small object in .NET is a pointer bump inside a per-thread allocation context, so
newitself 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."
| 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.
| 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.
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.
| 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.
| 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 |
| 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 |
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.
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.
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.
Thread A context Thread B context unused Gen 0 budget
+--------------------+ +--------------------+ +----------------------+
|obj|obj| free | |obj| free | | |
+--------------------+ +--------------------+ +----------------------+
^ptr ^limit ^ptr ^limit
new T() fields start at default without per-object work.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.
| 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) |
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.
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.
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.[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.
| 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) |
[MemoryDiagnoser] and compare Allocated and Gen0 columns.foreach over a List<int> cast to IEnumerable<int> in a loop and measure the enumerator boxing.dotnet-counters allocation rate; then add a ToList() in the hot path and watch it rise.dotnet-trace collect --profile gc-verbose, open it in PerfView, and use the "GC Heap Alloc Ignore Free" view to find top allocating stacks.GC.CollectionCount(0) for the same workload under Workstation and Server GC (<ServerGarbageCollector>).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}");
}
new is expensive like mallocWhat 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.
GCgen0size to get fewer GCsWhat 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.
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.
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.
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).
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.
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.
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.
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.
stackalloc without boundsWhat 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.
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.
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.
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.
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.
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].
Last updated 31 Oct 2026.