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.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.
Go deeper
- Fundamentals of garbage collection (.NET documentation) ↗learn.microsoft.com
- The large object heap (.NET documentation) ↗learn.microsoft.com
- Workstation and server garbage collection (.NET documentation) ↗learn.microsoft.com
- Garbage collector config settings, including DATAS (.NET documentation) ↗learn.microsoft.com