📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is Server GC in .NET, how does DATAS change it in .NET 9 and later, and how do you tune it in containers?

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.

Senior Theory Advanced containers datas gc kubernetes server-gc

🎯 Key points

  • Server GC uses one heap per logical CPU with dedicated parallel GC threads
  • It favours throughput with larger budgets and higher memory use
  • ASP.NET Core apps default to Server GC
  • DATAS dynamically adapts heap count and budgets; default with Server GC since .NET 9
  • The GC honours container CPU and memory limits; default hard limit is 75% of the memory limit
  • GCHeapHardLimit, GCHeapCount and GCConserveMemory give finer control
  • Leave headroom for native memory to avoid OOMKilled

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"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, GCHeapCount or GCConserveMemory only when measurements justify it."


1. How classic Server GC works

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.

2. DATAS in .NET 9+

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.

3. Containers: what the GC reads

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

4. Tuning settings

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.

5. Why pods still get OOMKilled

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.

6. My container checklist

  1. Set CPU requests/limits matching real need (or rely on DATAS but still avoid unlimited CPU on huge nodes).
  2. Set a memory limit and keep the default 75% managed limit unless measurements say otherwise.
  3. Log GCSettings.IsServerGC, ProcessorCount and GC.GetGCMemoryInfo().TotalAvailableMemoryBytes at startup.
  4. Load-test in the real container shape; compare p99 latency, CPU and memory per pod.
  5. Only then adjust HeapCount, ConserveMemory or DATAS.

Production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Heaps and threads in classic Server GC

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)
  • Each heap's GC thread marks objects reachable from roots it owns and helps others (work stealing during mark).
  • Allocation balancing moves a thread to a less busy heap if its home heap's Gen 0 is exhausted faster than others.
  • Cross-heap references are normal; the heaps are one logical managed heap.

2. How DATAS adapts

At a high level:

  1. Start with one heap.
  2. After GCs, measure the cost of GC (time spent in GC relative to the app's work) and the live data size.
  3. If GC cost is above target and the app is busy, add heaps (more parallelism, more budget).
  4. If the app is quiet and GC cost is low, remove heaps and shrink budgets toward a size derived from the live data.

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:

  • Right after startup or a load spike, you may see a short period of higher GC frequency while heap count grows.
  • Setting GCHeapCount fixes the count and effectively disables the adaptation.

3. Where memory goes in a pod

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.

4. Reading the configuration at run time

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.

5. Hexadecimal environment values

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%.

6. Multiple processes per VM

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.

7. Version notes

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

8. Try it yourself

  1. Run the code example with DOTNET_gcServer=1, then add DOTNET_GCDynamicAdaptationMode=0; compare committed MB and Gen 0 counts.
  2. Run both in Docker with --cpus=8 --memory=1g, then with no --cpus, and log ProcessorCount and TotalAvailableMemoryBytes.
  3. Set DOTNET_GCHeapHardLimitPercent=70 (wrong) vs 0x46 (right) and print TotalAvailableMemoryBytes.
  4. Load-test a sample ASP.NET Core app with a burst pattern (idle → heavy → idle) and watch committed memory follow the load with DATAS.
  5. Allocate 300 MB of native memory with NativeMemory.Alloc in a 512 MB container and observe the OOM kill even though the GC heap is small.

💻 Code

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

⚠ Common mistakes

1. Server GC pods with no CPU limit on large nodes

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.

2. Setting the GC hard limit equal to the container limit

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.

3. Writing decimal values in DOTNET_GC* environment variables

What happens: DOTNET_GCHeapHardLimitPercent=70. Why it is wrong: parsed as hex 0x70 = 112%. Do instead: use hex (0x46) or runtimeconfig.json (decimal).

4. Being unaware of DATAS

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.

5. Turning DATAS off by reflex

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.

6. Fixing GCHeapCount and expecting DATAS to work

What happens: both settings applied. Why it is wrong: a fixed heap count removes DATAS's main lever. Do instead: choose one approach deliberately.

7. Tuning on a laptop

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.

8. Running many Server GC processes on one VM without limits

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.

9. Blaming the GC for native memory growth

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).

10. Not logging the effective GC configuration

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate suggests Workstation GC for small pods Why: Checks Workstation GC trade-offs
    How to answer

    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.

  2. When: Candidate mentions OOMKilled Why: Checks container memory diagnosis
    How to answer

    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.

  3. When: Candidate mentions long pauses at peak Why: Checks full GC investigation
    How to answer

    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.

🛠 Real-world problems: how you handle them

Idle API used 1.5 GB per pod on 16-core nodes
Situation
An ASP.NET Core API on .NET 8 ran on [16-core] Kubernetes nodes without CPU limits.
Signals
Each pod used [about 1.5 GB] even at night; node memory forced extra nodes.
Investigation
`GCSettings.IsServerGC` was true and `dotnet-counters` showed large Gen 0 budgets across [16] heaps.
Resolution
Set a CPU limit matching real usage, then upgraded to .NET 9 where DATAS shrank idle memory further.
Prevention
Added memory-per-pod budgets and GC mode logging at startup.

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.

↩ Usually asked after

More in Garbage Collection

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me