Allocation patterns decide throughput and tail latency of .NET services; tuning starts with the GC model.
The .NET GC is a generational, compacting, tracing collector. It finds live objects by tracing from roots (stack variables, statics, GC handles) and frees everything else, then compacts survivors to avoid fragmentation.
Generations exploit the fact that most objects die young:
- Gen 0: new allocations. Collected often and cheaply.
- Gen 1: a buffer for objects that survived one collection.
- Gen 2: long-lived objects; full collections are expensive.
- The Large Object Heap (objects of 85,000 bytes or more, typically big arrays) is collected with gen 2 and not compacted by default; churning large buffers hurts, so pool them with
ArrayPool<T>. - The Pinned Object Heap holds objects allocated as pinned.
Allocation itself is very cheap (bumping a pointer in a per-thread allocation context); the cost is paid at collection time, and grows with the number of surviving objects. Mid-life objects that survive into gen 2 and then die (caches without limits, per-request data captured in long-lived objects) are the worst case.
Modes: Workstation GC for client apps, Server GC (default in ASP.NET Core) with a heap per core for throughput; concurrent/background GC performs most gen 2 work alongside your threads. DATAS (dynamic adaptation, default with Server GC since .NET 9) resizes heaps to fit the actual load, which helps in containers.
Diagnose with dotnet-counters (GC heap size, gen collection counts, % time in GC), dotnet-trace and dotnet-gcdump. GC.Collect() in production code is almost always wrong.
Example
static void Report(string label)
=> Console.WriteLine($"{label}: gen0={GC.CollectionCount(0)} gen1={GC.CollectionCount(1)} gen2={GC.CollectionCount(2)}");
Report("start");
for (int i = 0; i < 1_000_000; i++)
{
var tmp = new byte[100]; // short-lived: dies in gen 0
}
Report("after 1M small allocs"); // many gen0, few or no gen2
var small = new object();
var large = new byte[100_000]; // >= 85,000 bytes: LOH
Console.WriteLine(GC.GetGeneration(small)); // 0
Console.WriteLine(GC.GetGeneration(large)); // 2 (LOH is logically gen 2)
GC.Collect(); // for demonstration only
Console.WriteLine(GC.GetGeneration(small)); // 1 (survived one GC)
Console.WriteLine(GC.GetTotalAllocatedBytes() > 100_000_000); // True
Console.WriteLine(System.Runtime.GCSettings.IsServerGC); // False in a console appRemember
- Most objects should die young in gen 0, which is cheap
- Survivors into gen 2 and LOH churn are what make GC expensive
- ASP.NET Core uses Server GC; DATAS adapts heap count to load
- Measure with dotnet-counters and dotnet-trace before tuning
Watch out for
- Calling GC.Collect() to 'free memory', which promotes live objects and wastes CPU.