Server GC heaps and threads, throughput versus memory, DATAS dynamic heap count (default with Server GC since .NET 9), and container settings such as GCHeapHardLimit and GCHeapCount.
Server GC creates one heap per logical CPU (limited by affinity and container CPU limits) with dedicated, high-priority GC threads that collect in parallel. Budgets are larger, so GCs are less frequent and throughput is high, but memory use is higher. It is the default for ASP.NET Core. DATAS (Dynamic Adaptation To Application Sizes), opt-in in .NET 8 and on by default with Server GC since .NET 9, starts with fewer heaps and adjusts the heap count and budgets to the live data size and load, so a lightly loaded service uses far less memory. In containers, the GC honours CPU and memory limits; GCHeapHardLimit (or ...Percent), GCHeapCount and GCConserveMemory give finer control.
"Server GC is the throughput-oriented GC. Classically it creates one heap per logical CPU the process can use, each with its own generations, and runs collections with dedicated, high-priority GC threads in parallel. Budgets are larger, so there are fewer GCs and great throughput, at the cost of more memory. It's the default for ASP.NET Core. The big recent change is DATAS, Dynamic Adaptation To Application Sizes: opt-in in .NET 8 and on by default with Server GC since .NET 9. It starts with a small number of heaps and grows or shrinks heap count and budgets based on load and live data size, so an idle service on a big machine no longer holds gigabytes. In containers, the GC sizes itself from the CPU and memory limits; I set limits deliberately, leave headroom for native memory, and use settings like
GCHeapHardLimitPercent,GCHeapCountorGCConserveMemoryonly when measurements justify it."
| Aspect | Server GC |
|---|---|
| Heaps | N = logical CPUs available (affinity, container CPU limit) |
| Per heap | Its own Gen 0/1/2 regions, LOH and POH areas |
| Allocation | Threads allocate from a heap associated with their core; the allocator balances across heaps |
| GC threads | One dedicated GC thread per heap, high priority; mark/plan/compact in parallel |
| Background GC | Background Gen 2 with dedicated background threads per heap |
| Budgets | Larger per heap → fewer GCs |
| Trade-off | Highest throughput, highest memory |
Parallel marking means pause times scale better with heap size than Workstation GC on many-core machines.
| Classic Server GC | Server GC + DATAS | |
|---|---|---|
| Heap count | Fixed at startup (one per core) | Starts at 1 and adapts up/down |
| Gen 0 budget | Large per heap, scaled by core count | Derived from live data size and throughput goals |
| Idle service on 32 cores | Can commit large amounts of memory | Shrinks toward what the app actually needs |
| Under heavy load | Max throughput | Grows heaps; close to classic throughput |
| Config | GarbageCollectionAdaptationMode=0 |
GarbageCollectionAdaptationMode=1 (default in .NET 9+ with Server GC) |
DATAS aims to keep the cost of GC within a target while sizing the heap to the application's live data, which suits bursty and multi-tenant container workloads.
When I'd turn DATAS off: a dedicated, always-busy service where every last bit of throughput matters and memory is plentiful, and only after an A/B load test shows a gain.
| Input | GC behaviour |
|---|---|
| CPU limit (cgroup quota) | Environment.ProcessorCount and maximum Server GC heap count |
| Memory limit | Default GCHeapHardLimit = max(20 MB, 75% of the limit) |
| No limits | Assumes the whole node: classic Server GC can create a heap per node core |
| Setting | runtimeconfig.json / MSBuild |
Use |
|---|---|---|
| Server GC on/off | System.GC.Server / ServerGarbageCollector |
Mode choice |
| DATAS | System.GC.DynamicAdaptationMode / GarbageCollectionAdaptationMode |
1 on, 0 off |
| Heap hard limit | System.GC.HeapHardLimit or HeapHardLimitPercent |
Explicit cap on the managed heap |
| Heap count | System.GC.HeapCount |
Fix the number of heaps (disables DATAS's adaptation) |
| Conserve memory | System.GC.ConserveMemory (0–9) |
More compaction to reduce fragmentation |
| Affinity | System.GC.HeapAffinitizeMask, System.GC.NoAffinitize |
Several Server GC processes on one host |
Environment-variable equivalents use the DOTNET_GC... prefix, and their numeric values are hexadecimal.
The GC limit covers only the managed heap. Thread stacks, native libraries (SQL clients, compression, gRPC), memory-mapped files, JIT'd code and native buffers all add to the container's usage. If the managed limit is close to the container limit, the kernel's OOM killer acts before the GC can respond. Leave headroom, and watch working set as well as GC heap size.
GCSettings.IsServerGC, ProcessorCount and GC.GetGCMemoryInfo().TotalAvailableMemoryBytes at startup.HeapCount, ConserveMemory or DATAS.After moving to .NET 8, our [orders API] ran on [16-core] Kubernetes nodes with no CPU limit. Each pod sat at [about 1.5 GB] even overnight, and node memory forced the cluster to scale out. Startup logs showed IsServerGC = true and ProcessorCount = 16; dotnet-counters showed large committed memory across 16 heaps. I set a CPU limit of [2] cores to match real usage, which brought idle memory down to [about 600 MB]. After upgrading to .NET 9, DATAS adapted the heap count and idle memory fell to [a few hundred MB], while a load test showed peak throughput and p99 within [2%] of before. We fitted [X] more pods per node and added a memory-per-pod budget to our dashboards.
The interviewer checks production-grade knowledge of GC behaviour in containers.
Green flags: heaps per core, dedicated GC threads, throughput versus memory, DATAS default since .NET 9, container limits and 75% default, hard limit and heap count settings, hex environment values, native memory headroom.
Red flags: unaware of DATAS; sets the hard limit to 100%; no idea why memory grows with node size.
Core 0 Core 1 Core 2 Core 3
[Heap 0] [Heap 1] [Heap 2] [Heap 3]
gen0/1/2 gen0/1/2 gen0/1/2 gen0/1/2
LOH/POH LOH/POH LOH/POH LOH/POH
^ GC thread ^ GC thread ^ GC thread ^ GC thread (parallel mark/compact)
At a high level:
The Gen 0 budget in DATAS is tied to live data (bounded), instead of being tied to core count. That is why an idle 32-core service doesn't commit a huge Gen 0 per core anymore.
Side effects to know:
GCHeapCount fixes the count and effectively disables the adaptation.Container memory limit (e.g. 1 GiB)
+------------------------------------------------------------+
| Managed GC heap (default cap 75% = ~768 MiB) |
| gen0..2, LOH, POH, fragmentation, committed-but-free |
+------------------------------------------------------------+
| Native: thread stacks, native libs, JIT code, R2R images, |
| SQL/HTTP native buffers, mmap'd files |
+------------------------------------------------------------+
If native usage is 300 MiB and the managed heap is allowed 768 MiB, the pod can exceed 1 GiB and be killed while the GC thinks it has room. Lower HeapHardLimitPercent (or raise the container limit) based on measured native usage.
using System.Runtime;
var info = GC.GetGCMemoryInfo();
Console.WriteLine($"Server GC: {GCSettings.IsServerGC}");
Console.WriteLine($"CPUs visible: {Environment.ProcessorCount}");
Console.WriteLine($"GC memory limit: {info.TotalAvailableMemoryBytes / 1024 / 1024} MB");
Console.WriteLine($"High memory load threshold: {info.HighMemoryLoadThresholdBytes / 1024 / 1024} MB");
Console.WriteLine($"DATAS: {AppContext.GetData("System.GC.DynamicAdaptationMode") ?? "runtime default"}");
dotnet-counters (.NET 9+ System.Runtime metrics) and GC trace events also expose the number of heaps; in traces you can watch DATAS changing heap count over time.
| Intent | Environment variable | runtimeconfig.json |
|---|---|---|
| 70% heap limit | DOTNET_GCHeapHardLimitPercent=0x46 |
"System.GC.HeapHardLimitPercent": 70 |
| 4 heaps | DOTNET_GCHeapCount=4 (hex 4 = 4) |
"System.GC.HeapCount": 4 |
| 512 MB limit | DOTNET_GCHeapHardLimit=0x20000000 |
"System.GC.HeapHardLimit": 536870912 |
Getting this wrong is a classic incident: DOTNET_GCHeapHardLimitPercent=70 means 0x70 = 112%.
On a VM running several Server GC processes without limits, each assumes all cores: N processes × N heaps × N GC threads competing during GCs. Options: CPU limits per process/container, GCHeapCount, GCHeapAffinitizeMask/GCHeapAffinitizeRanges, or GCNoAffinitize.
| Version | Change |
|---|---|
| .NET Core 3.0 | Container awareness; GCHeapHardLimit; Server GC limited by CPU quota |
| .NET 5 | POH per heap |
| .NET 6 | GCConserveMemory available |
| .NET 7 | Regions on 64-bit |
| .NET 8 | DATAS opt-in (GarbageCollectionAdaptationMode=1); GC.RefreshMemoryLimit() |
| .NET 9 | DATAS on by default with Server GC |
| .NET 10 | DATAS refinements; same configuration surface |
DOTNET_gcServer=1, then add DOTNET_GCDynamicAdaptationMode=0; compare committed MB and Gen 0 counts.--cpus=8 --memory=1g, then with no --cpus, and log ProcessorCount and TotalAvailableMemoryBytes.DOTNET_GCHeapHardLimitPercent=70 (wrong) vs 0x46 (right) and print TotalAvailableMemoryBytes.NativeMemory.Alloc in a 512 MB container and observe the OOM kill even though the GC heap is small.using System.Runtime;
Console.WriteLine($"Server GC: {GCSettings.IsServerGC}, logical CPUs: {Environment.ProcessorCount}");
Console.WriteLine($"DATAS setting: {AppContext.GetData("System.GC.DynamicAdaptationMode") ?? "(runtime default)"}");
// Simulate request-style allocation across threads
Parallel.For(0, 2_000_000, i =>
{
var payload = new byte[256];
payload[0] = (byte)i;
});
GCMemoryInfo info = GC.GetGCMemoryInfo();
Console.WriteLine($"Heap size: {info.HeapSizeBytes / 1024 / 1024} MB");
Console.WriteLine($"Committed: {info.TotalCommittedBytes / 1024 / 1024} MB");
Console.WriteLine($"Hard limit: {(info.TotalAvailableMemoryBytes / 1024 / 1024)} MB available to GC");
Console.WriteLine($"Gen 0 GCs: {GC.CollectionCount(0)}");
Console.WriteLine($"Time in GC: {GC.GetTotalPauseDuration().TotalMilliseconds:N0} ms paused");
<!-- Web SDK already enables Server GC; these are explicit for illustration -->
<PropertyGroup>
<ServerGarbageCollector>true</ServerGarbageCollector>
<!-- 1 = DATAS on (default with Server GC since .NET 9), 0 = off -->
<GarbageCollectionAdaptationMode>1</GarbageCollectionAdaptationMode>
</PropertyGroup>
# Kubernetes: the GC sizes itself from these limits
resources:
limits:
cpu: "2"
memory: "1Gi"
env:
- name: DOTNET_GCHeapHardLimitPercent
value: "0x46" # 70% (hexadecimal) of the memory limit for the managed heap
What happens: classic Server GC creates a heap per node core.
Why it is wrong: memory per pod scales with node size, not need.
Do instead: set CPU limits, rely on DATAS (.NET 9+), or set GCHeapCount.
What happens: HeapHardLimitPercent = 100.
Why it is wrong: native memory is outside the GC limit; the kernel kills the pod.
Do instead: leave headroom based on measured native usage; monitor working set.
DOTNET_GC* environment variablesWhat happens: DOTNET_GCHeapHardLimitPercent=70.
Why it is wrong: parsed as hex 0x70 = 112%.
Do instead: use hex (0x46) or runtimeconfig.json (decimal).
What happens: advice from older blogs ("Server GC = one heap per core") is applied to .NET 9/10. Why it is wrong: heap count is dynamic by default now. Do instead: check the runtime version and DATAS setting before reasoning about memory.
What happens: a team disables DATAS because GC counts went up after upgrading. Why it is wrong: more, cheaper GCs with lower memory can be the intended trade; latency may be unchanged. Do instead: A/B load-test latency, CPU and memory before deciding.
GCHeapCount and expecting DATAS to workWhat happens: both settings applied. Why it is wrong: a fixed heap count removes DATAS's main lever. Do instead: choose one approach deliberately.
What happens: settings tested on a 12-core dev machine without limits. Why it is wrong: GC sizing depends on CPU and memory limits of the real deployment. Do instead: test in the real container shape with realistic load.
What happens: five services each create a heap and GC thread per core.
Why it is wrong: GC threads compete, pauses lengthen, memory multiplies.
Do instead: CPU limits, GCHeapCount, or affinity settings per process.
What happens: OOM kills are treated as GC tuning problems. Why it is wrong: GC settings can't reduce native allocations. Do instead: compare GC heap with working set; investigate native usage (see the OOMKilled question).
What happens: nobody knows which mode or limits production actually uses.
Why it is wrong: investigations start from wrong assumptions.
Do instead: log IsServerGC, ProcessorCount, TotalAvailableMemoryBytes and DATAS setting at startup.
Server GC tuning is about matching the runtime's assumptions to the deployment.
| Symptom | Likely fix |
|---|---|
| High idle memory on big nodes | CPU limits, DATAS, or fewer heaps |
| OOMKilled with GC heap below limit | Native memory; lower GC limit, find native growth |
| GC pauses long at peak | Ensure enough heaps (CPU), reduce live objects |
| Several processes per VM | GCHeapCount or affinity settings |
Seniors test GC settings under realistic load in the real container shape, not on a developer laptop.
Workstation GC uses a single heap and runs collections on the thread that triggered them (with a background GC thread for concurrent Gen 2 work). It aims for small memory footprint and short pauses rather than maximum throughput. It is the default for console, desktop and worker apps that do not use the Web SDK; ASP.NET Core apps default to Server GC. You enable it with <ServerGarbageCollector>false</ServerGarbageCollector>; background (concurrent) GC is on by default and can be turned off with <ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>. Choose Workstation GC for desktop apps, CLI tools, and many small containers where memory density matters more than throughput.
RAM is physical memory. Each process gets a virtual address space that the OS maps onto RAM page by page. The working set is what's resident in RAM right now; private (committed) memory is what the process owns. The GC heap is only one part: native allocations (native libraries, NativeMemory, unmanaged buffers), thread stacks, JIT code, and runtime structures add to it. In Kubernetes, the cgroup limit counts all of it, and going over gets the container OOMKilled (exit code 137), not an OutOfMemoryException. So if the GC heap is small but the pod dies, I compare GC heap size with working set in dotnet-counters. A big gap points to native memory or too many threads; a growing GC heap points to a managed leak I'd chase with dotnet-gcdump. Then I fix the cause and set limits and GC settings deliberately.
A Gen 2 GC collects the whole managed heap: Gen 0, Gen 1, Gen 2, the LOH and the POH. It is triggered when Gen 2's budget is exceeded by promotions, when large object allocations exceed the LOH budget, under memory pressure (approaching the container or GCHeapHardLimit), or by code calling GC.Collect(). Most Gen 2 GCs run as background GCs that let threads keep running; blocking full GCs (with compaction) pause everything. To investigate frequent Gen 2 GCs, check the GC reason (dotnet-trace or PerfView GCStats), then the cause: LOH churn, mid-life objects, induced collections, or memory limit pressure.
How to tell it (STAR)
Our [API] used [1.5 GB] per idle pod because Server GC created a heap per node core. I set CPU limits and, after the .NET 9 upgrade, DATAS adapted the heap count. Idle memory dropped to [a few hundred MB] with the same peak throughput.
Last updated 31 Oct 2026.