The .NET garbage collector is generational — it divides objects into generations based on their age, then collects younger generations more frequently than older ones. This design is built on an empirical observation: most objects die young.

The Three Generations

Generation 0 contains newly allocated objects. Most objects never survive their first collection. Gen 0 collections are fast because they only examine a small portion of the heap.

Generation 1 acts as a buffer between short-lived and long-lived objects. Objects that survive a Gen 0 collection are promoted to Gen 1. Gen 1 collections are slightly more expensive but still relatively cheap.

Generation 2 contains long-lived objects — singletons, caches, static data, objects that have survived multiple collections. Gen 2 collections are the most expensive because they examine the entire managed heap.

Example.cs
// Check which generation an object is in
var data = new byte[100];
Console.WriteLine(GC.GetGeneration(data)); // 0

GC.Collect(0);
Console.WriteLine(GC.GetGeneration(data)); // 1

GC.Collect(1);
Console.WriteLine(GC.GetGeneration(data)); // 2

Collection Triggers

The GC runs when:

  1. Gen 0 fills up. The Gen 0 budget (typically a few MB) is exhausted. This triggers a Gen 0 collection.
  2. Gen 1 fills up. If Gen 1 is full after a Gen 0 collection, a Gen 1 collection runs.
  3. System memory is low. The OS signals memory pressure.
  4. GC.Collect() is called. Explicitly forcing a collection (generally discouraged in production code).

Each collection of generation N also collects all lower generations. A Gen 2 collection is a full GC — it examines everything.

The Generational Hypothesis in Practice

The hypothesis that most objects die young holds remarkably well for typical .NET applications:

Example.cs
// These objects die immediately — Gen 0
public IEnumerable<string> GetNames(IEnumerable<Person> people)
{
    return people.Select(p => $"{p.First} {p.Last}"); // temporary strings
}

// This object lives forever — Gen 2
private static readonly Dictionary<string, Handler> _handlers = new();

The GC exploits this by making Gen 0 collections extremely fast. On server GC, a Gen 0 collection typically completes in under a millisecond.

The Large Object Heap (LOH)

Objects larger than 85,000 bytes are allocated directly on the Large Object Heap, regardless of their expected lifetime. The LOH has several important properties:

Example.cs
// This array lands on the LOH (85,000+ bytes)
var largeArray = new byte[100_000];
Console.WriteLine(GC.GetGeneration(largeArray)); // 2 (LOH is collected with Gen 2)

LOH Compaction

You can request LOH compaction, but it is expensive:

Example.cs
GCSettings.LargeObjectHeapCompactionMode =
    GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect(); // compaction happens on this collection
// Mode automatically resets to Default after one compaction

This is a last resort for applications suffering severe LOH fragmentation. A better approach is to avoid LOH allocations in the first place.

Avoiding LOH Allocations

Use ArrayPool. Rent arrays from ArrayPool<byte>.Shared instead of allocating them directly. Pooled arrays are reused rather than collected.

Use RecyclableMemoryStream. Microsoft's RecyclableMemoryStream uses pooled buffers internally, preventing large MemoryStream backing arrays from landing on the LOH.

Example.cs
using Microsoft.IO;

var manager = new RecyclableMemoryStreamManager();

using var stream = manager.GetStream();
await JsonSerializer.SerializeAsync(stream, data);

Avoid large string concatenations. Use StringBuilder or string.Create to avoid building large intermediate strings.

The Pinned Object Heap (POH)

.NET 5 introduced the Pinned Object Heap for objects that must remain at a fixed memory address (typically for interop with native code). Previously, pinned objects on the regular heap prevented compaction. The POH isolates them.

Example.cs
// Allocated on the POH
byte[] pinned = GC.AllocateArray<byte>(1024, pinned: true);

Monitoring Generations

Use dotnet-counters or EventPipe to track collection frequency:

dotnet-counters monitor -p <pid> System.Runtime

Healthy patterns:

You can also use GC.CollectionCount() in code:

Example.cs
int gen0Before = GC.CollectionCount(0);
// ... do work ...
int gen0After = GC.CollectionCount(0);
Console.WriteLine($"Gen 0 collections: {gen0After - gen0Before}");

Summary

The generational model is the foundation of .NET's memory management. Gen 0 collections are cheap and frequent; Gen 2 collections are expensive and should be rare. The LOH handles large objects but suffers from fragmentation — avoid it with pooling. Understanding these mechanics helps you write code that works with the GC rather than against it.