AlgoMaster Logo

Garbage Collection

High Priority25 min readUpdated June 6, 2026

C# is a managed language: you allocate objects with new and the runtime decides when to free them. The piece of the .NET runtime that does the freeing is the garbage collector, or GC. This lesson covers how the GC decides what's still in use, the generational design that makes it fast, the two big collection modes (workstation versus server), the latency knobs available, and the small set of GC API calls that show up in real code.

The Reachability Rule

Before anything else, the GC has to answer one question: which objects are still in use? The rule is simple. An object is reachable if some live piece of code can still get to it, directly or by following references through other objects. Everything else is garbage.

The starting points for "live code can get to it" are called GC roots. There are four kinds, often mentioned in profilers and memory tools:

  • Stack roots: local variables and method parameters in any thread's currently executing call stack. As long as MakeOrder() is running, every Order, Cart, and Product reference sitting in its locals is rooted.
  • Static fields: any static field on any loaded type. A static Dictionary<int, Product> _catalog keeps every product it holds alive for as long as the type is loaded, which in most apps means forever.
  • GC handles: explicit pinned or strong handles created through APIs like GCHandle.Alloc. Native interop uses these to pin a managed object's address so the unmanaged side can write to it without the GC moving it.
  • The finalizer queue: objects that have a finalizer and are waiting for it to run. The queue counts as a root, so a missed finalizer can keep objects alive longer than expected.

Starting from those roots, the GC walks the reference graph. Every object it can reach by following at least one chain of references is marked live. Everything else is unreachable and its memory can be reused. The graph walk is the heart of the algorithm.

A subtle consequence: reachability is the only thing the GC looks at. It doesn't care whether an object is "useful," whether the developer "meant" to keep it, or whether some piece of code might theoretically still want it. If there's a chain of references from a root to the object, the object stays alive, full stop. That's why the common managed-memory bug looks nothing like a leak in C++: there's no missed delete, just an object that is still being referenced somewhere. A static event handler attached to a long-lived publisher, a cache that never evicts, an IDisposable field stored on a singleton, all of these keep their targets alive without any further new calls.

The graph walk reads every live reference field on every live object. The work is proportional to the surviving set, not the total heap. A heap full of short-lived garbage collects faster than a heap full of long-lived caches.

The cyan boxes are the roots. The green boxes are reachable from at least one root. OldDraft has nothing pointing at it, so it's garbage and the next collection can reclaim its memory. The GC is never told "this is garbage." It infers it from the absence of references.

The Generational Hypothesis

If the GC had to walk the entire reference graph on every collection, it would be slow on any reasonably sized heap. The trick that makes it fast is an observation that holds for almost every program ever written:

Most objects die young.

Strings built inside one method, intermediate LINQ results, the temporary StringBuilder used to format a label, the loop's var current = items[i] reference, all of these become unreachable shortly after they're created. A small fraction of objects survive long enough to matter: the catalog dictionary at startup, the singleton logger, the cached config. Almost nothing lives in the middle.

This is called the generational hypothesis, and the .NET GC builds its whole design around it. New objects start in a small region called Gen 0. The GC collects Gen 0 often, because that's where the garbage piles up. The few objects that survive a Gen 0 collection are promoted to Gen 1. Surviving a Gen 1 collection promotes them again to Gen 2, where they live until they become unreachable.

GenerationTypical contentsCollection frequencyCollection cost
Gen 0New, short-lived objectsVery frequentCheap (small region)
Gen 1Survivors of one collection (buffer)Less frequentCheap to moderate
Gen 2Long-lived objects (caches, statics)RareExpensive (walks the whole heap)

The design spends most of the GC's effort on the region where most of the garbage is. A Gen 0 collection walks roots and the small Gen 0 region; it doesn't have to scan Gen 1 or Gen 2 except to follow references that point into Gen 0 from older generations. That cross-generation reference tracking is what the card table (and the related write barrier) does, so the runtime knows which parts of Gen 1 and Gen 2 might be pointing at Gen 0 without rescanning them in full.

Two more regions sit alongside the generations:

  • The Large Object Heap (LOH) holds any single allocation of 85,000 bytes or more. Big arrays, large strings, big buffers. LOH objects skip Gen 0 and Gen 1; they're allocated directly in the LOH and treated as part of Gen 2 for collection purposes. The reason is mechanical: large objects are expensive to copy during compaction, so the GC normally doesn't compact the LOH. That trade-off is fine when large allocations are rare, but it can cause fragmentation when large arrays of varying sizes are created and dropped repeatedly.
  • The Pinned Object Heap (POH), added in .NET 5, is a separate region for objects that must be pinned (typically for interop). Pinning a regular heap object prevents the GC from moving it during compaction, which hurts compaction; routing those allocations through the POH keeps them out of the way.

A Gen 2 collection walks the entire managed heap, including the LOH. On a large heap, it can pause every managed thread for tens or hundreds of milliseconds. The whole generational design exists to keep Gen 2 collections rare. Frequent Gen 2 collections signal that too much is surviving Gen 0.

You can inspect the generations from code with GC.GetGeneration(object), which returns the generation an object currently lives in:

The cart starts in Gen 0. Each forced collection promotes it one step, so after two collections it's in Gen 2. Real code rarely needs to call GC.Collect() and the next section explains why, but GetGeneration is useful inside diagnostics and tests for confirming an object's lifetime behavior.

Mark, Sweep, Compact

A single collection in the .NET GC uses a mark-sweep-compact algorithm. Three phases, each with one job:

Mark. Start from the roots. Walk the reference graph. Set a "live" bit on every reachable object. When the walk finishes, every unmarked object is garbage.

Sweep. Scan the heap. The marked objects are kept. The unmarked spans of memory are recorded as free. At this point the heap looks like Swiss cheese: live objects with holes between them.

Compact. Slide the live objects together to one end of the heap, eliminating the holes. References that pointed at the moved objects are updated to point at their new addresses. After compaction, the heap has one contiguous free region at the end, and the next allocation is just "bump the pointer."

The compaction step is what makes the .NET allocator cheap. Because every collection leaves the active generation as a contiguous block with free space at the end, allocating a new object is essentially "increment the next-free pointer by the object's size." There's no free-list search and no fragmentation hunt. That's a big part of why allocating in a managed language can be faster than malloc in C.

The downside of compaction is that objects move. Their addresses change between collections, so any native code that holds a raw pointer to a managed object has to pin it first. The GC respects the pin and works around it; too many pinned objects, though, defeat the compaction step and leave gaps. The Pinned Object Heap exists to keep those gaps out of the normal generations.

The LOH normally isn't compacted, for the cost reason mentioned earlier. A one-off LOH compaction can be requested by setting GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; before a forced collection, but in a healthy app this is almost never needed.

One more thing about the mark phase. Marking has to be transitive: if Cart references Order and Order references Product, marking Cart as live doesn't automatically mark Order and Product. The GC has to follow the reference graph, marking each object as it encounters it for the first time. To avoid infinite loops on cyclic graphs (a Cart whose Customer field points back at a Customer whose LastCart field points back at the cart), the live bit doubles as a "visited" marker; once an object is marked, the GC doesn't follow its fields again. A graph with cycles costs the same to walk as a graph without them, as long as the total number of reachable objects is the same.

Workstation, Server, and Background

The "the GC" is shorthand for a small family of modes. The two top-level choices are workstation GC and server GC, and each can be combined with background (concurrent) GC on top.

Workstation GC is the default for desktop and most non-server apps. There's one GC heap and one GC thread doing the work. It's tuned for low latency on a process that shares a machine with other apps: it keeps the per-collection pause short, even if total throughput is a bit lower. Small services, GUI apps, and most console tools default to this mode.

Server GC is built for big back-end processes that own most of the machine. It splits the managed heap into one slice per logical CPU, with a dedicated GC thread per slice. Collections happen in parallel across cores, which makes them much faster on multi-core machines, at the cost of higher memory usage (each slice keeps its own free space) and longer worst-case pauses. ASP.NET Core enables server GC by default in production.

Server GC is opt-in at the project level. In the .csproj:

The MSBuild property emits the right setting into the application's runtimeconfig.json, which is what the runtime reads at startup. It can also be set directly in runtimeconfig.json or via the DOTNET_gcServer=1 environment variable.

Background GC (also called concurrent GC) is the orthogonal switch. When background GC is on, the expensive Gen 2 mark phase runs on a separate GC thread, concurrently with application threads. The application is only paused briefly at the start and end of the cycle to coordinate, and most of the marking happens while the app keeps running. Background GC is on by default in both workstation and server modes; it can be disabled for fully synchronous collections:

Turning it off is rarely appropriate for a normal application. It mostly exists for narrow scenarios like benchmarking or specific real-time-style workloads where deterministic pause behavior matters more than total pause time.

ModeHeap layoutGC threadsBest for
Workstation, background on (default)Single heapOne main GC thread plus a concurrent helperDesktop apps, small services
Workstation, background offSingle heapOne synchronous GC threadSpecialized low-determinism scenarios
Server, background on (ASP.NET Core default)One heap per logical CPUOne GC thread per heap, plus concurrent helpersLarge back-end services
Server, background offOne heap per logical CPUSynchronous parallel collectionThroughput-sensitive batch jobs

You can confirm at runtime which mode the process is using:

GC.MaxGeneration is 2 on every modern .NET version, which is the design's hard limit. There's no Gen 3.

The practical implication of choosing server GC. On a 16-core machine, an ASP.NET Core service running server GC has 16 GC heaps and 16 GC threads. A Gen 0 collection runs in parallel across all 16 cores; if it would have taken 80 ms under workstation GC, it might take 10 ms under server GC. Throughput per request goes up because per-request allocations are reclaimed faster. The cost is that each per-CPU heap keeps its own free space, so the total resident memory of the process is higher than the same workload under workstation GC. For a service that owns its host, that trade-off is usually right. For a small console tool that runs for two seconds, the overhead of standing up per-CPU heaps wouldn't pay back, which is why workstation GC remains the default outside of web workloads.

The GC API: Useful Calls

The System.GC and System.Runtime.GCSettings types expose a handful of calls that show up in real code, mostly for diagnostics. The ones to know, organized by what they're for.

Inspecting the heap.

GC.GetTotalMemory(bool forceFullCollection) returns the current managed heap size in bytes. With true, it triggers a full collection first so the number reflects only live objects. With false, it's a snapshot that includes whatever garbage hasn't been collected yet. The "force" overload fits a deterministic measurement; the "no force" overload fits cheap sampling.

GC.GetTotalAllocatedBytes(bool precise) returns a running total of how many bytes the application has ever allocated, across all generations. It only goes up. With precise: true, the number is up to date; with false, it's cheaper but slightly stale. Subtracting two snapshots gives the allocation rate of whatever ran in between, the most useful number for finding hot allocation paths.

Inspecting individual objects and generations.

GC.GetGeneration(object obj) returns the generation a specific object currently lives in. Useful in tests and diagnostics that need to assert "this should have been collected by now" or "this should have made it to Gen 2."

GC.CollectionCount(int generation) returns how many collections have happened for the given generation since the process started. A high Gen 2 count over a short window signals something is wrong; healthy applications collect Gen 2 rarely.

Triggering collections (use sparingly).

GC.Collect() forces a full collection. GC.Collect(int generation) forces a collection of the given generation and everything below it; GC.Collect(0) is a Gen 0 collection, GC.Collect(2) is a full collection. There's also GC.Collect(generation, mode, blocking) for finer control over whether the collection is forced or merely "optimized" and whether it should block. The same caveat applies to every overload: the runtime usually knows better than the caller when to collect.

A small diagnostics example:

Output (illustrative, numbers vary):

The loop allocated about 8.6 MB of strings, but only about 250 KB are still alive. Two Gen 0 collections happened, and zero Gen 2 collections, the healthy pattern the generational design aims for: cheap collections that clean up short-lived work without ever escalating.

GC.GetTotalAllocatedBytes(precise: true) walks per-thread allocation contexts to refresh the total, which has a measurable cost on hot paths. Use the precise overload sparingly, sample it in a background thread or at the start and end of a measurement window, not inside a tight loop.

When (Not) To Call GC.Collect

The most useful thing to know about GC.Collect() is that it should almost never be called. The GC is tuned around long-running observation of the app's allocation patterns. Triggering collections by hand throws those signals off, promotes things to Gen 2 that the GC would otherwise have collected cheaply in Gen 0, and turns short pauses into longer ones.

The few legitimate uses, in rough order of frequency:

  • Tests and benchmarks that want a clean baseline before measuring. Call GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); before the measured section so leftover garbage from previous tests doesn't pollute the numbers.
  • One-off cleanup at a known idle point. A batch processor that has just finished a 5 GB import and won't touch that memory again might force a collection before going idle, so the OS-visible memory drops sooner.
  • After dropping a very large data structure that the GC won't notice for a while because allocation has gone quiet. This is rare and usually a sign that the design should hold the structure differently.

Calling GC.Collect() to fix a "memory issue" usually means the issue is something else: a static collection that never releases entries, an event subscription that keeps an object alive, a using block that wasn't a using block. Profile first, then fix the actual cause.

GC.Collect() with no argument is a full blocking Gen 2 collection. It walks the entire heap and pauses every managed thread. On a 1 GB heap, that's tens to hundreds of milliseconds of stop-the-world latency.

Common Patterns That Defeat the GC

The generational design assumes objects either die young or live a long time. A few real patterns break that assumption and turn cheap collections into expensive ones.

Allocations near the LOH threshold. Code that produces lots of arrays in the 80,000 to 100,000-byte range alternately lands them on the small-object heap and the LOH depending on exact size. A loop that reads orders into byte[] buffers sized by message length can fragment the LOH badly. The fix is usually ArrayPool<byte>.Shared.Rent(...) from System.Buffers, which is the topic of the _Object Pooling & ArrayPool_ lesson; renting and returning the same handful of buffers keeps allocations off the LOH entirely.

Mid-life crisis. This is the GC engineer's term for objects that live long enough to escape Gen 0 but die soon after, getting promoted to Gen 1 (or even Gen 2) and then collected from there. Promotion is fine; collecting from an older generation is what hurts. The signal is unusually high Gen 1 or Gen 2 collection counts under steady-state load. The fix is either to shorten the object's lifetime so it dies in Gen 0, or to lengthen it so it stays alive long enough to belong in Gen 2.

Long-lived caches without eviction. A static Dictionary<int, Product> that only ever has entries added eventually fills with stale data and ends up in Gen 2. Every Gen 2 collection has to walk it. The fix is usually a real cache implementation with size limits and eviction (MemoryCache, IMemoryCache, or one of the third-party caches) instead of a raw dictionary.

Event handlers that outlive their owners. Subscribing to an event on a long-lived publisher keeps the subscriber alive for as long as the publisher exists. A page or view model that subscribes to a singleton service's event and then "goes away" is still rooted by the subscription, and so are all of its fields. This isn't really a GC bug, it's a logic bug, but it shows up as a GC symptom (the working set never goes down). The fix is to unsubscribe in the equivalent of a destructor or Dispose method, which is the next two chapters' territory.

A diagram of healthy versus unhealthy patterns:

The top row is the healthy pattern: short-lived objects die where they were born, and the collection cost is small. The bottom row is what happens when objects barely escape Gen 0, the mid-life-crisis pattern. The cure is rarely to call GC.Collect(); it's to change the code that causes the escape.

Latency Modes

The GC normally tunes itself for a balance of throughput and pause time. When an app has a temporary phase where pauses are unusually expensive (rendering a frame, handling a real-time tick), the GC can be asked to back off using GCSettings.LatencyMode.

ModeBehavior
InteractiveDefault. Balanced; allows full collections when needed.
BatchThroughput-focused. Suspends concurrent GC; collections are bigger but rarer. Good for batch jobs where total time matters more than pause length.
LowLatencyAvoids Gen 2 collections except when memory is critically low. Use for short, time-sensitive sections.
SustainedLowLatencySame goal as LowLatency but designed to be held for longer periods. Used by latency-sensitive servers.

Setting a latency mode is a hint, not a guarantee. If the runtime is about to run out of memory, it will still collect Gen 2 regardless of the mode.

The difference between LowLatency and SustainedLowLatency is duration. LowLatency is for a brief, performance-critical section, on the order of seconds. SustainedLowLatency is for a server that needs the same behavior for minutes or hours, and the GC compensates by being more aggressive about Gen 0 and Gen 1 collections during the window so it doesn't run out of headroom. Batch is the opposite trade-off: it gives the GC permission to take longer pauses in exchange for getting more done per pause. A nightly report-generation job that doesn't care about pause time but does care about total wall-clock time is the canonical use case.

When using these modes, restore the previous mode in a finally block so an exception doesn't leave the process pinned in a non-default mode forever. Keep the window short: the longer the process stays in LowLatency, the more garbage piles up, and the worse the eventual catch-up collection becomes.

For even tighter pause guarantees, there's a separate no-GC region API.

No-GC Regions

GC.TryStartNoGCRegion(long totalSize) asks the runtime to guarantee no full collections happen until totalSize bytes have been allocated. If the runtime can promise the budget (it has enough free space already), the call returns true and the process is in a no-GC region. GC.EndNoGCRegion() releases the guarantee.

A no-GC region is the strongest pause guarantee the runtime offers, and it comes with strict rules. If the code allocates more than the budget, the region ends early and a regular collection happens. The window should be short and the budget realistic; this is effectively borrowing free space from the heap for the duration. The API exists for cases like trading systems and game engines where a single dropped frame matters. Application code almost never needs it.

There are two flavors of the call. GC.TryStartNoGCRegion(long totalSize) reserves a budget on the small-object heap only. The overload GC.TryStartNoGCRegion(long totalSize, long lohSize) reserves separate budgets for the small-object heap and the LOH, which matters when latency-critical code allocates large arrays. Both return false if the runtime can't satisfy the request, for example because the heap doesn't have enough contiguous free space available. Always check the return value before assuming a no-GC region is active; running latency-sensitive code under the wrong assumption that pauses are disabled can be worse than not trying at all.

A no-GC region prevents the runtime from reclaiming memory while it's open. With too high a budget or too long a region, the heap grows and the catch-up collection after EndNoGCRegion is more expensive than the collections being avoided.

Putting It Together

A small program ties the pieces together. It allocates short-lived and long-lived data, watches the per-generation collection counters, and reports the heap size at the end:

Output (illustrative):

The catalog dictionary survived enough Gen 0 collections to get promoted twice, so it ends up in Gen 2. About 17 MB of strings were allocated during the loop, but only about 120 KB are still live. Four cheap Gen 0 collections were enough to keep the heap small; no Gen 2 collection was needed. That's the generational design working as intended: the catalog stays in long-lived storage and the short-lived garbage gets cleaned up cheaply.

Three observations from the example. First, the live heap at the end is two orders of magnitude smaller than the total bytes allocated; that ratio is normal and matches what the GC is built to handle. Second, the catalog reached Gen 2 not through forcing but because it was alive when collections happened, the natural way for long-lived objects to get there. Third, the string allocations inside the loop never escape their iteration, so they die in Gen 0; appending each one to a List<string> would promote them along with the list and change the Gen 1 and Gen 2 counts.

The same program with GC.Collect() called inside the loop would have produced very different numbers. Each forced collection would have promoted everything still reachable, including transient locals, into older generations. The collection count would have skyrocketed, total pause time would have grown, and the per-iteration cost would have gone up. That's the practical reason "don't call GC.Collect()" is the default advice.

A small variant of the example. The same loop, but this time each iteration appends the label to a list that escapes to a static field:

Output (illustrative):

Same allocation volume as the original loop, but the live heap is now huge because every string is held alive by the static Labels list. A Gen 2 collection can't reclaim any of them, because every entry is reachable from a GC root. The fix isn't a GC.Collect() call; it's to either bound the list (drop old entries when it grows past some size) or not store the strings in the first place if nothing consumes them. This is the failure mode the "common patterns" section describes: an unbounded long-lived collection turning short-lived data into long-lived data by reference.

The bigger lesson from the example is the shape of healthy GC behavior, not the absolute numbers. A high ratio of allocated bytes to live heap, combined with a healthy ratio of Gen 0 to Gen 2 collections, is the target. Profilers like dotnet-counters (run dotnet-counters monitor --counters System.Runtime against a live process) surface the same numbers continuously and are the standard tool for watching them during load tests. If the Gen 2 count starts climbing or the live heap won't come down between collections, something is keeping objects alive longer than expected. The next step is to take a snapshot with dotnet-gcdump and look at what's rooting the largest objects. The runtime cooperates with all of these tools, but it can't identify which static field is the culprit; that requires inspection.

Quiz

Garbage Collection Quiz

11 quizzes