Allocations & Garbage Collection
10 min readAllocations & Garbage Collection
TL;DR
In .NET, allocation itself is cheap — a pointer bump. What costs is collection, and collection cost is driven by how many objects survive, not by how many you created. That single inversion explains almost everything: short-lived garbage is nearly free, long-lived caches are expensive, and the worst thing you can do is allocate objects that live just long enough to be promoted out of gen 0. Tuning here means reducing allocation on hot paths and, more importantly, not creating mid-lifetime objects.
How it works
Generational collection, and why it works
.NET's GC divides the heap into three generations plus the Large Object Heap:
- Gen 0 — brand-new objects. Collected constantly and very cheaply.
- Gen 1 — survived one collection. A buffer between short- and long-lived.
- Gen 2 — survived several. Collected rarely and expensively; a full gen-2 collection walks the whole heap.
- LOH — objects ≥ 85,000 bytes. Collected only with gen 2, and not compacted by default.
The generational hypothesis is that most objects die young, and it is overwhelmingly true for request-scoped work. A gen-0 collection only walks the surviving objects, so if almost everything is garbage, it is nearly free — this is why "allocating a lot" is not automatically a problem.
The corollary is what matters: an object that survives gen 0 gets promoted, and promotion is what costs. Objects that live for a medium duration — cached for a few seconds, held in a queue, captured by a long-running task — get promoted into gen 2 and then require an expensive collection to clean up. This is called mid-life crisis, and it is far worse than either short-lived garbage or genuinely permanent data.
Where allocations hide
// 1. Closures capture into a heap-allocated display class.
var results = items.Where(x => x.Id == targetId); // captures targetId
// 2. Boxing: value type -> object.
object boxed = 42;
IComparable c = 5; // also boxing
string.Format("{0}", someInt); // boxes the int
// 3. LINQ chains allocate an enumerator per operator, per enumeration.
var top = orders.Where(o => o.IsActive)
.OrderBy(o => o.Date) // allocates a sorted buffer
.Select(o => o.Total)
.Take(10);
// 4. String concatenation in a loop -- one new string per iteration.
foreach (var line in lines) report += line; // O(n^2) copying
// 5. async state machines when the method actually completes synchronously.
None of these are wrong in themselves. They are wrong on a hot path, and identifying which paths are hot is the profiling step.
The techniques, in the order you should reach for them
- Pre-size collections. Removes every intermediate array and the garbage it becomes.
StringBuilderfor repeated concatenation — one growing buffer instead of n strings.Span<T>/ReadOnlySpan<char>for parsing and slicing without allocating substrings.ArrayPool<T>.Sharedfor large temporary buffers — rent, use, return in afinally.ValueTaskfor async methods that usually complete synchronously (cache hits).structfor small, short-lived, immutable data — but beware copy costs and boxing.ArrayPool/ object pooling for genuinely expensive objects only. Pooling cheap objects usually makes things worse: you convert free gen-0 garbage into long-lived gen-2 objects.
Server GC versus Workstation GC
Workstation GC is the default for client apps: one heap, tuned for low latency on a single-user machine. Server GC allocates a heap and a dedicated collection thread per core, dramatically improving throughput for a multi-core server at the cost of higher memory use. ASP.NET Core enables Server GC by default. Inside a container this matters enormously: if the container's CPU limit is not visible to the runtime, Server GC can spin up heaps for every host core, and a memory limit that looked generous is suddenly exhausted. Setting DOTNET_gcServer and respecting container limits is a standard production tuning step.
Background/concurrent GC performs most gen-2 work on a background thread, reducing pause times but not eliminating them.
Memory leaks in a garbage-collected language
The GC frees unreachable objects, so a "leak" in .NET is always unintended reachability. The usual causes:
- A static collection or cache that only ever grows.
- Event handlers never unsubscribed — the publisher holds the subscriber alive.
- A captured
thisin a long-lived lambda or timer callback. IDisposableobjects holding unmanaged resources never disposed.
The diagnostic is a dotnet-gcdump at two points and comparing what grew, then looking at the retention path — what is keeping it alive — rather than what the object is.
A: Allocation is a pointer bump in a contiguous nursery — the runtime increments a pointer and returns the previous value, which is a handful of instructions. Collection cost, by contrast, is proportional to the objects that survive, because the collector traces reachable objects from the roots and copies or compacts them; dead objects cost nothing, they are simply not visited. That inversion is why generating large volumes of short-lived garbage is nearly free while retaining a moderate number of medium-lived objects is expensive, and why the right optimisation target is usually survival rate rather than allocation count.
A: It is when objects live long enough to survive gen-0 and gen-1 collections and be promoted into gen 2, but not long enough to be genuinely permanent — objects cached for a few seconds, sitting in a queue, or captured by a medium-lived task. It is the worst case because gen-0 collections are cheap precisely when almost nothing survives, while gen-2 collections are expensive and rare; promoting a steady stream of objects into gen 2 means paying the promotion cost and forcing frequent expensive collections to clean them up again. The design implication is that objects should either die immediately or live for the process lifetime, and the shapes to avoid are short-lived caches and buffers held just past their natural scope.
A: Workstation GC uses a single heap tuned for low latency on a single-user machine, while Server GC creates a separate heap and dedicated collection thread per core, which greatly improves allocation throughput on multi-core servers at the cost of higher memory usage. ASP.NET Core enables Server GC by default. In a container it matters because the runtime sizes those per-core structures from the CPU count it can see: if the container's CPU limit is not correctly surfaced, the runtime provisions heaps for every host core, and memory usage far exceeds what the container limit allows, producing an OOM kill that looks inexplicable. Ensuring the runtime honours cgroup limits, or explicitly configuring the GC mode and heap count, is a standard production step.
A: The collector frees only unreachable objects, so a leak is always unintended reachability — something is still holding a reference. The usual causes are a static collection or in-process cache that only grows because it has no eviction policy, event handlers that are never unsubscribed so the publisher keeps every subscriber alive, lambdas or timer callbacks capturing this and thereby the whole object graph behind it, and undisposed objects wrapping unmanaged resources such as sockets or file handles. Diagnosis is a heap dump at two points in time, comparing what grew, and then inspecting the retention path rather than the object type — because the leaking object is rarely the interesting part; whatever is holding it is.
A: It holds allocations of 85,000 bytes or more, which in practice means large arrays, big strings, and buffers. It creates two problems: it is only collected as part of a gen-2 collection, so large temporary buffers keep memory occupied far longer than their logical lifetime, and it is not compacted by default, so a pattern of allocating and releasing differently-sized large arrays fragments it until an allocation fails despite substantial free memory. The mitigations are to pre-size collections so a single allocation replaces a doubling sequence, to rent reusable buffers from ArrayPool<T>.Shared rather than allocating fresh ones, and to chunk data so no individual array crosses the threshold.
A: It is good for objects that are genuinely expensive to create or that are large enough to hit the LOH — big byte buffers, database connections, and similar. ArrayPool<T>.Shared is the standard vehicle, with the rented array returned in a finally block. It backfires for cheap short-lived objects, because it converts free gen-0 garbage into long-lived pooled objects that survive into gen 2, which is precisely the mid-life-crisis pattern you were trying to avoid — you pay more in collection cost than you save in allocation. Pooling also introduces real correctness hazards: a rented buffer contains the previous tenant's data unless cleared, and forgetting to return it is a leak with none of the GC's protection.
A: Replace substring extraction with ReadOnlySpan<char> slicing, which creates a view over the existing characters rather than allocating a new string per field — often taking the allocation count in the loop to zero. Use the span-based TryParse overloads so numbers are parsed directly from the span without an intermediate string. Build output with a StringBuilder or by writing into a pooled buffer rather than concatenating. And where a large working buffer is needed, rent it from ArrayPool<T>.Shared instead of allocating one per call. The constraint to remember is that Span<T> is a ref struct confined to the stack, so it cannot be stored in a field, captured in a lambda, or held across an await; Memory<T> covers the async cases.
A: I look at which generation is collecting, because the answer points in completely different directions. A high gen-0 rate with a low promotion rate means a high allocation volume on a hot path, and the fix is to reduce allocations there — spans, pooled buffers, pre-sized collections, avoiding closures and boxing in the loop. A high gen-2 rate means objects are surviving into the old generation, which points at a growing cache, a leak, or the mid-life-crisis pattern, and the fix is a heap dump to find what is being retained and by what. I would also check whether the LOH is involved, since large-array churn drives gen-2 collections directly, and whether Server GC is configured appropriately for the container's actual CPU and memory limits.
Key takeaways
- Allocation is a pointer bump; collection cost scales with survivors, not with allocations.
- Short-lived garbage is nearly free. Mid-lifetime objects promoted to gen 2 are the expensive case.
- Hidden allocations: closures, boxing, LINQ enumerators, string concatenation in loops, async state machines.
- Order of attack: pre-size →
StringBuilder→Span<T>→ArrayPool→ValueTask→ structs → pooling (last). - Server GC is per-core heaps — in a container that misreads CPU limits, it is a memory-blow-up waiting to happen.
- A .NET "leak" is unintended reachability: static caches, unsubscribed events, captured
this, undisposed resources. - Diagnose by generation: gen-0 heavy = allocation rate; gen-2 heavy = retention. Different fixes entirely.