Garbage collection in .NET is what lets you allocate objects freely without ever calling free, and it is also one of the most common reasons a service uses more memory or shows more latency than expected. This guide is for experienced developers who run .NET in production, especially in containers. You will learn how the generational, compacting GC organizes the heap, how workstation, server and background GC differ, what DATAS changed in .NET 9 and .NET 10, which settings matter in containers, how finalization and weak references interact with the collector, and how to measure and tune GC behavior with evidence.
What Is the .NET Garbage Collector?#
The .NET GC is a precise, generational, mostly compacting, tracing collector. Precise means the runtime knows exactly which stack slots, registers and fields hold object references, because the JIT emits GC information for every method. Tracing means it finds live objects by walking references from roots: locals and registers in active frames, static fields, GC handles (including pinned handles) and the finalization queue. Everything it cannot reach is garbage.
The GC has two halves. The allocator hands out memory and decides when a collection is needed. The collector finds live objects, reclaims the rest and usually compacts survivors so free space stays contiguous. Because the collector can move objects, allocation can stay extremely cheap: most allocations just advance a pointer.
How the .NET GC Works#
Generations 0, 1 and 2#
The GC relies on the observation that most objects die young. The small object heap (SOH) is divided into three generations. New objects start in gen0; survivors of a gen0 collection are promoted to gen1, which acts as a buffer; survivors of gen1 go to gen2, which holds long-lived data such as caches and singletons. Collecting a generation also collects every younger one, so a gen2 collection is a full GC.
Each generation has an allocation budget that the GC adjusts based on survival rates. When gen0's budget is exhausted, a gen0 GC runs; a high survival rate grows the budget so the next collection is more productive. To collect gen0 without scanning gen2, the GC must know about references from old objects to young ones. The JIT emits write barriers on reference stores, and the barrier marks a card table, so an ephemeral GC scans only the old-generation memory whose cards are set.
The large object heap and the pinned object heap#
Objects of 85,000 bytes or more go to the large object heap (LOH), sometimes called gen3. The LOH is collected only during gen2 collections and is swept rather than compacted by default, because copying big arrays is expensive. You can request a one-time compaction with GCSettings.LargeObjectHeapCompactionMode, and the threshold is configurable with System.GC.LOHThreshold.
Since .NET 5 there is also a pinned object heap (POH). Pinning an ordinary object with a pinned handle or fixed prevents compaction around it and fragments gen0 if the pin lives long. Allocating a buffer directly on the POH with GC.AllocateArray<T>(length, pinned: true) keeps long-lived pinned buffers away from the compacting generations. The LOH and POH together are called the user old heap (UOH).
Regions instead of segments#
Starting with .NET 7, the GC on 64-bit Windows and Linux organizes memory as regions rather than large segments. By default an SOH region is 4 MB and a UOH region is 32 MB. At startup the GC reserves (but does not commit) a virtual address range, 256 GB by default or five times the heap hard limit when one is set. Regions can be handed from one generation to another or returned to the OS individually, which makes decommitting memory much more flexible than with segments. GCRegionSize and GCRegionRange are tunable, with runtimeconfig names added in .NET 10, but rarely need changing.
Allocation contexts#
Each thread allocates from its own allocation context, a chunk handed out by the GC in allocation quanta of typically 8 KB. The GC zeroes the chunk up front, so allocating a small object is a pointer bump with no lock. When the context is exhausted, the thread asks for a new quantum, and when the generation's budget is exceeded, that request triggers a GC. This design is why short-lived allocations are cheap, and why high allocation rates rather than high object counts drive GC frequency.
What happens during a collection#
A blocking collection suspends managed threads at safe points and runs these phases:
| Phase | What it does |
|---|---|
| Mark | Traces from roots (and set cards, for ephemeral GCs) to find live objects |
| Plan | Simulates compaction to decide whether compacting is worth it |
| Relocate | Updates every reference to objects that will move |
| Compact | Copies survivors to their new addresses |
| Sweep | If not compacting, turns dead space into free-list entries |
Getting Started: Observe the GC From Inside Your App#
Before changing any setting, measure. GC.GetGCMemoryInfo returns details about the last collection, and a few static methods give process-wide totals:
using System.Runtime;
GCMemoryInfo info = GC.GetGCMemoryInfo(GCKind.Any);
Console.WriteLine($"Mode: {(GCSettings.IsServerGC ? "Server" : "Workstation")}, " +
$"latency mode: {GCSettings.LatencyMode}");
Console.WriteLine($"Collections: gen0={GC.CollectionCount(0)}, " +
$"gen1={GC.CollectionCount(1)}, gen2={GC.CollectionCount(2)}");
Console.WriteLine($"Heap {info.HeapSizeBytes / 1_048_576} MB, " +
$"committed {info.TotalCommittedBytes / 1_048_576} MB, " +
$"fragmented {info.FragmentedBytes / 1_048_576} MB");
Console.WriteLine($"Memory available to the GC: {info.TotalAvailableMemoryBytes / 1_048_576} MB");
Console.WriteLine($"Paused {GC.GetTotalPauseDuration().TotalMilliseconds:N0} ms in total, " +
$"{info.PauseTimePercentage:N1}% of process lifetime");
string[] names = ["gen0", "gen1", "gen2", "LOH", "POH"];
ReadOnlySpan<GCGenerationInfo> generations = info.GenerationInfo;
for (var i = 0; i < generations.Length && i < names.Length; i++)
{
Console.WriteLine($" {names[i]}: {generations[i].SizeAfterBytes / 1024:N0} KB after last GC");
}TotalAvailableMemoryBytes is worth checking first in a container: it shows how much memory the GC believes it may use, which inside a container is the heap hard limit derived from the memory limit. PauseTimePercentage is the sum of all pauses divided by process lifetime, a quick health indicator for throughput-sensitive services.
Workstation vs Server GC and Background GC#
.NET offers two GC flavors. Workstation GC uses a single heap and runs collections on the thread that triggered them; it is the default for console, desktop and worker apps and is always used on a machine with one logical CPU. Server GC creates a heap and a dedicated GC thread (high priority on Windows) per logical CPU and collects all heaps in parallel, which gives much higher allocation throughput. ASP.NET Core projects get Server GC by default because the Web SDK sets ServerGarbageCollection to true.
Both flavors use background GC by default. Gen2 collections run concurrently with your code on dedicated threads, while gen0 and gen1 collections, called foreground GCs, can still happen during a background collection. Background GC shortens pauses for full collections but does not compact; when the GC needs to shrink the heap, it performs a full blocking, compacting collection instead.
Server GC's weakness is density. Classic Server GC sized itself to the machine, not to the workload, so ten services on one node could each claim one heap per core and large gen0 budgets. That is the problem DATAS solves.
DATAS: Dynamic Adaptation to Application Sizes#
DATAS was introduced as an opt-in setting in .NET 8 and became the default for Server GC in .NET 9; it remains on in .NET 10. Instead of starting with one heap per core, DATAS starts with one heap and adds or removes heaps based on a throughput cost target, while sizing the gen0 budget from the amount of long-lived data. The result is a heap that tracks your live data size rather than your core count.
Key knobs, all read at startup:
System.GC.DynamicAdaptationMode(DOTNET_GCDynamicAdaptationMode): set to0to disable DATAS.System.GC.DTargetTCP: the throughput cost percentage DATAS aims for, 2% by default. A higher target accepts more GC cost in exchange for fewer heaps; a lower one does the opposite.System.GC.DGen0GrowthPercent,DGen0GrowthMinFactorandDGen0GrowthMaxFactor: added in .NET 10 to scale and clamp the gen0 budget DATAS computes.
To see the trade-off concretely, a deliberately allocation-heavy synthetic test (64 MB of live data and four threads allocating short strings for eight seconds) was run on a 4-vCPU Linux container with .NET 10.0.12. The numbers are from one environment and will differ for your workload, but the pattern repeated across runs:
| Configuration | gen0 GCs | Committed memory | Time paused |
|---|---|---|---|
| Workstation, background GC | about 2,600 | 81 MB | about 12% |
| Server GC with DATAS (default) | about 6,400 | 70 MB | about 25% |
| Server GC, DATAS disabled | about 600 | 161 MB | about 16% |
DATAS delivered the smallest footprint, less than half of classic Server GC, by collecting far more often. For most services that trade is right, because memory is what limits pod density. For a latency-sensitive service with spare memory and very high allocation rates, disabling DATAS or raising the gen0 budget can be the better choice. Measure your own workload before deciding.
Configuring the GC: Hard Limits, Conserve Memory and Containers#
GC settings are read once at startup from runtimeconfig.json (usually generated from MSBuild properties or runtimeconfig.template.json) or from DOTNET_ environment variables. Environment variables take hexadecimal values, so DOTNET_GCHeapHardLimit=0xC800000 means 200 MiB.
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
<!-- 1 = DATAS on (default for Server GC since .NET 9), 0 = off -->
<GarbageCollectionAdaptationMode>1</GarbageCollectionAdaptationMode>
</PropertyGroup>{
"configProperties": {
"System.GC.HeapHardLimitPercent": 70,
"System.GC.ConserveMemory": 5,
"System.GC.HighMemoryPercent": 85
}
}The GC is container-aware. When a cgroup memory limit is present and you set nothing, the heap hard limit defaults to 75% of the container limit (or 20 MB, whichever is larger), and the GC treats the container limit as total physical memory. The settings that matter most:
GCHeapHardLimitorGCHeapHardLimitPercentcap the GC's committed memory. Leave headroom for native memory, thread stacks and JIT code; 75% is a sensible default, not a target.GCConserveMemory(0 to 9) makes the GC work harder to keep the heap small, including automatic LOH compaction when fragmentation is high. Microsoft suggests starting between 5 and 7.GCHighMemoryPercent(default 90%) is the memory load at which the GC becomes aggressive about compacting full collections.GCHeapCountand the affinitize settings limit the number of Server GC heaps, which matters most when DATAS is off and many processes share a node.GC.RefreshMemoryLimit()(.NET 8 and later) makes the GC re-read limits after a container's memory limit changes at run time.
Environment variables are convenient in container images because they do not require a rebuild:
FROM mcr.microsoft.com/dotnet/aspnet:10.0
ENV DOTNET_GCHeapHardLimitPercent=0x46 \
DOTNET_GCConserveMemory=5
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "Orders.Api.dll"]The Docker best practices guide covers image size and memory limits in more depth.
Reducing GC Work: Pooling, Pinning and Large Buffers#
The cheapest collection is the one that never happens. Three techniques address the most common sources of GC cost: renting instead of allocating large temporary buffers, allocating long-lived pinned buffers on the POH, and avoiding hidden large allocations.
using System.Buffers;
public sealed class FrameReader(Stream source) : IDisposable
{
// Long-lived buffer reused for async I/O: allocate it once on the POH so pinning
// it during reads never fragments the compacting generations.
private readonly byte[] _header = GC.AllocateUninitializedArray<byte>(4096, pinned: true);
public async Task<int> ReadFrameAsync(CancellationToken ct)
{
await source.ReadExactlyAsync(_header.AsMemory(0, 8), ct);
int length = BitConverter.ToInt32(_header, 4);
// Large temporary buffer: rent it instead of allocating on the LOH per call.
byte[] payload = ArrayPool<byte>.Shared.Rent(length);
try
{
await source.ReadExactlyAsync(payload.AsMemory(0, length), ct);
return Checksum(payload.AsSpan(0, length));
}
finally
{
ArrayPool<byte>.Shared.Return(payload);
}
}
private static int Checksum(ReadOnlySpan<byte> data)
{
var sum = 0;
foreach (var b in data)
{
sum = unchecked(sum * 31 + b);
}
return sum;
}
public void Dispose() => source.Dispose();
}Span<T>-based APIs let you slice rented buffers without copying; the Span and Memory guide explains the patterns. Watch for hidden LOH allocations too: a List<T> or MemoryStream that grows past 85,000 bytes reallocates on the LOH at every doubling.
Finalization, IDisposable and Weak References#
A type that overrides Finalize (a C# finalizer) is registered in the finalization queue when it is allocated. When the GC finds such an object unreachable, it cannot free it yet: the object is queued for the finalizer thread, survives the collection, and is reclaimed only by a later GC. Reclaiming finalizable objects therefore takes at least two collections, and they are promoted along the way. Finalizers also run in no guaranteed order, on an unspecified thread, and an exception in a finalizer terminates the process. Since .NET Core, finalizers are not run when the process exits.
The practical rules are to wrap native handles in SafeHandle (which already has a hardened finalizer), implement IDisposable for deterministic cleanup, and add a finalizer only as a safety net for resources nobody else owns:
public sealed class TempWorkspace : IDisposable
{
public string Path { get; } = Directory.CreateTempSubdirectory("orders-").FullName;
public void Dispose()
{
DeleteQuietly();
GC.SuppressFinalize(this); // cleanup done: skip the finalization queue
}
// Safety net only: runs late, on the finalizer thread, and never at process exit.
~TempWorkspace() => DeleteQuietly();
private void DeleteQuietly()
{
try { Directory.Delete(Path, recursive: true); }
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
}Weak references let you observe an object without keeping it alive. A short WeakReference<T> clears when the object is collected; a long one (trackResurrection: true) survives until after finalization. Use them for caches of expensive but re-creatable data, and use ConditionalWeakTable<TKey, TValue> to attach data to objects you do not own without extending their lifetime.
Measuring GC With Counters, Traces and Dumps#
Since .NET 9, the System.Runtime meter publishes GC metrics such as dotnet.gc.collections (tagged by generation), dotnet.gc.heap.total_allocated, dotnet.gc.last_collection.heap.size, dotnet.gc.last_collection.memory.committed_size and dotnet.gc.pause.time. Any OpenTelemetry exporter can collect them, and dotnet-counters shows them live:
# Live view of GC metrics for a running process
dotnet-counters monitor --process-id 1902 --counters System.Runtime
# Low-overhead trace of every GC, for PerfView's GCStats view
dotnet-trace collect --process-id 1902 --profile gc-collect --duration 00:00:02:00
# Heap snapshot (object counts and sizes) without a full memory dump
dotnet-gcdump collect --process-id 1902Use the gc-verbose trace profile when you also need allocation sampling, and a full dotnet-dump with SOS commands such as gcroot when you must find what keeps objects alive. The diagnostics toolkit guide walks through these tools end to end.
Choosing a GC Mode: Workstation vs Server vs DATAS#
| Workload | Recommended configuration | Why |
|---|---|---|
| Desktop, CLI and most worker services | Workstation, background GC (default) | Low footprint, responsive UI threads |
| ASP.NET Core API in containers | Server GC with DATAS (default since .NET 9) | Throughput with a heap that tracks live data |
| Latency-critical service with spare memory | Server GC, DATAS off or a lower DTargetTCP | Fewer, larger gen0 GCs |
| Many small services per node | Server GC with DATAS, or Workstation | Avoids one heap per core in every process |
| Memory-constrained pod | DATAS plus GCConserveMemory 5 to 7 and a hard limit | Smaller heap at the cost of more frequent GCs |
| Batch job maximizing throughput | Server GC with GCLatencyMode.Batch | Batch mode disables background collections |
Tuning Recipes#
| Symptom | Likely cause | What to try |
|---|---|---|
| High gen2 count and long pauses | Mid-life objects surviving into gen2 (caches, pooled per request) | Reduce lifetimes, bound caches, check PromotedBytes |
| OOM kills in containers below the limit | Native memory plus GC heap exceeding the cgroup limit | Lower GCHeapHardLimitPercent, audit native allocations |
| Growing LOH and fragmentation | Repeated large temporary buffers | ArrayPool<T>, pre-sized collections, GCConserveMemory |
| High gen0 GC rate | Very high allocation rate | Allocation profiling, spans, pooling, fewer closures and boxing |
| Memory much larger than expected | Server GC without DATAS on a large machine | Re-enable DATAS or cap GCHeapCount |
Best Practices#
- Measure before tuning. Record GC counts, pause percentage and heap size under realistic load, and change one setting at a time.
- Allocate less rather than configure more. Pooling, spans and avoiding needless intermediate collections beat any GC setting.
- Keep the defaults unless evidence says otherwise. Server GC with DATAS and background GC is a strong default for modern services.
- Leave headroom in containers. The GC heap is only part of process memory; thread stacks, native libraries and JIT code need space too.
- Dispose deterministically. Use
usingdeclarations for everything that implementsIDisposable, and reserve finalizers for rare safety nets. - Avoid
GC.Collect()in production code. Induced full GCs throw away the GC's own heuristics; use them only in tests and tooling.
Common Pitfalls#
- Assuming Server GC is always better. On small containers or dense nodes, classic Server GC can multiply memory usage.
- Long-lived pins in gen0. Pinning buffers for asynchronous I/O fragments the ephemeral generations; allocate such buffers on the POH instead.
- Relying on finalizers at shutdown. Modern .NET does not run finalizers at process exit, so flush and close resources explicitly.
- Caching without bounds. An unbounded dictionary is a memory leak with a nice name; use size limits or expiration.
- Hidden LOH churn. Large JSON documents,
MemoryStreamgrowth and string concatenation of big payloads allocate on the LOH repeatedly. - Using
GetTotalMemory(true)in hot paths. It forces a full collection; preferGC.GetGCMemoryInfoor metrics.
Frequently Asked Questions#
What are the generations in the .NET garbage collector?#
The small object heap has three generations: gen0 for new objects, gen1 as a buffer for objects that survived one collection, and gen2 for long-lived objects. Large objects of 85,000 bytes or more go to the large object heap, which is collected together with gen2. Collecting a generation also collects all younger generations.
Is DATAS enabled by default in .NET 9 and .NET 10?#
Yes, for Server GC. DATAS was opt-in in .NET 8 and became the default for Server GC in .NET 9, and it remains the default in .NET 10. Workstation GC is unaffected, and you can disable DATAS with System.GC.DynamicAdaptationMode set to 0.
Should I use Server GC or Workstation GC in Kubernetes?#
For ASP.NET Core services, keep the default Server GC, which uses DATAS in .NET 9 and later and adapts its heap count to the workload. Consider Workstation GC for very small pods with fractional CPU limits or many processes per node. Always validate with real load and container limits.
How does the .NET GC behave in containers?#
The GC reads cgroup limits and treats the container memory limit as the machine's physical memory. With no explicit setting, the heap hard limit defaults to 75% of that limit, and the GC compacts more aggressively as memory load approaches 90%. GC.RefreshMemoryLimit() lets it pick up limit changes at run time.
Why does my .NET process use more memory than the GC heap size?#
The GC heap is only one part of the process. Thread stacks, native libraries, JIT-compiled code, memory-mapped files and allocations made outside the GC all add to the working set. Compare dotnet.gc.last_collection.memory.committed_size with the process working set to see how much memory is outside the managed heap.
Summary#
- The .NET GC is precise, generational and mostly compacting, with gen0, gen1, gen2, the LOH for objects of 85,000 bytes or more and the POH for pinned buffers.
- Since .NET 7 the heap is organized as regions, and each thread allocates lock-free from its own allocation context.
- Workstation GC suits client and small workloads; Server GC suits services, and DATAS (default since .NET 9) makes it size the heap to live data.
- In containers the default heap hard limit is 75% of the memory limit;
GCConserveMemoryand hard limits trade CPU for footprint. - Finalizers delay reclamation and do not run at process exit; prefer
SafeHandleandIDisposable. - Measure with
GC.GetGCMemoryInfo,System.Runtimemetrics,dotnet-traceand dumps before and after every tuning change.