C# and .NET path · Types and memory · Topic 4 of 14

The garbage collector: generations, LOH, server GC

Not started

The .NET garbage collector is generational and, for the small object heap, compacting. Allocation is cheap: the runtime bumps a pointer in a per-thread allocation context. The cost comes later, when a collection has to find which objects are still reachable from roots (locals, statics, CPU registers, GC handles) and move the survivors together. The design bets that most objects die young, so it collects young objects often and old ones rarely.

Why it matters: GC pauses and memory growth are the most common .NET performance complaints, and nearly every fix starts from knowing which generation the garbage is in.

  1. 1Three generations

    New objects go in gen 0. Survivors of a collection are promoted to gen 1, then gen 2. Young generations are collected far more often than old ones.

  2. 2Large objects are different

    Objects of 85,000 bytes or more go on the large object heap, which is collected only with gen 2 and is swept rather than compacted by default.

  3. 3Workstation or server

    Console apps default to workstation GC (one heap); ASP.NET Core apps default to server GC (a heap and a GC thread per core), adjusted by DATAS since .NET 9.

  4. 4Allocation is the lever

    Fewer and shorter-lived allocations mean fewer and cheaper collections. Measure before tuning settings.

1

How it works

The explanation, with examples you can run.

Generations

using System.Runtime;var small = new object();Console.WriteLine($"new small object: gen {GC.GetGeneration(small)}");GC.Collect();Console.WriteLine($"after one GC: gen {GC.GetGeneration(small)}");GC.Collect();Console.WriteLine($"after two GCs: gen {GC.GetGeneration(small)}");var big = new byte[85_000];var almost = new byte[84_000];Console.WriteLine($"byte[85000]: gen {GC.GetGeneration(big)}, byte[84000]: gen {GC.GetGeneration(almost)}");Console.WriteLine($"server GC: {GCSettings.IsServerGC}");

On .NET 10 this printed gen 0, gen 1, gen 2, then byte[85000]: gen 2, byte[84000]: gen 0 and server GC: False.

  • Gen 0 holds new objects. When its budget is used up, a gen 0 collection runs: usually a short pause, because few objects are alive.
  • Gen 1 holds objects that survived one collection; it is a buffer between short-lived and long-lived data.
  • Gen 2 holds long-lived objects: caches, singletons, anything that has survived. A gen 2 collection examines the whole heap, so it is the expensive one. Background GC lets most of a gen 2 collection run while your threads keep working.

Collecting a generation also collects every younger one. The classic leak in a managed language is not unreachable memory but memory that stays reachable: a static dictionary that only grows, an event handler that is never unsubscribed, a cache with no expiry.

The large object heap

An allocation of 85,000 bytes or more goes to the large object heap (LOH), which is logically part of gen 2. Copying large objects is expensive, so the LOH is swept into a free list rather than compacted (you can request compaction through GCSettings.LargeObjectHeapCompactionMode). Every large allocation also has to be zeroed. Allocating many short-lived large arrays therefore triggers gen 2 collections and fragments memory. Rent buffers from ArrayPool<T>.Shared instead.

Workstation, server and DATAS

Workstation GC uses one heap and collects on the thread that triggered it. It is the default for console and desktop apps. Server GC creates a heap and a dedicated GC thread per logical CPU and collects them in parallel, giving higher throughput at the cost of more memory. Projects using the Web SDK set ServerGarbageCollection to true, so ASP.NET Core apps get it by default; a web file-based app printed server GC True in the same container.

Server GC used to grow large heaps on big machines even when the app was small. Since .NET 9, DATAS (dynamic adaptation to application sizes) is on by default with server GC: it starts with one heap and adds heaps as the workload needs them, so the heap size tracks the live data. For containers, the GC treats the container's memory limit as the machine's memory and by default caps the heap at 75% of it.

Before changing any setting, look at the numbers: dotnet-counters monitor shows allocation rate, collection counts per generation and time in GC for a running process.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.An object survives two garbage collections. Which generation is it in?
  2. 2.Why might a service that allocates many temporary 100 KB buffers see frequent gen 2 collections?
  3. 3.Which statement about server GC is true?

0 of 3 right so far. Get all three to finish this topic.