Process memory from the operating system's and the runtime's point of view: physical RAM versus virtual address space, working set, private and committed memory, the GC heap and native memory, container cgroup limits, GC heap hard limits, Server GC and DATAS, and a step-by-step investigation of OOMKilled pods.
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.
| Term | Meaning | Where you see it |
|---|---|---|
| Physical RAM | Actual memory chips | Node capacity |
| Virtual address space | The addresses a process can use (128 TB on 64-bit); mostly unused | Virtual size, "VSZ" (ignore it for sizing) |
| Committed / private bytes | Memory the process has asked the OS to back, private to it | Windows Private Bytes, dotnet-counters working set (approximation) |
| Working set / RSS | Pages currently resident in RAM | Task Manager, top, container metrics |
| GC heap | Managed objects | dotnet-counters gc-heap-size, GC events |
| cgroup memory usage | What the container is charged for, including page cache for files it wrote | kubectl top, container_memory_working_set_bytes |
NativeMemory.Alloc, Marshal.AllocHGlobal, unmanaged buffers in sockets and compression.GCHeapHardLimit or GCHeapHardLimitPercent.GCConserveMemory (0–9) makes the GC compact more aggressively to reduce fragmentation.OutOfMemoryException; if the process (GC plus native) hits the container limit first, the kernel kills it.kubectl describe pod shows OOMKilled and exit code 137; check when and how often.dotnet-counters monitor -p <pid> --counters System.Runtime (or the dotnet-monitor sidecar). Compare GC heap size, working set, thread count and allocation rate over time.dotnet-gcdump snapshots some time apart and diff them in Visual Studio or PerfView to find types that grow. Typical culprits: unbounded MemoryCache/static dictionaries, event handler leaks, captured closures, large buffers held across requests.dotnet-dump collect --type Full) and look at native heaps, or use OS tools (pmap, valgrind-style tools in a test environment). Common causes: undisposed native resources (HttpClient handlers, compression streams, image objects), a native library leak.A document-rendering service had a 1 GiB limit and was OOMKilled under load while gc-heap-size stayed around 300 MB. dotnet-counters showed working set climbing to the limit with thread count spiking to 400. Each request rendered PDFs with a native library on a synchronous thread-pool thread, and the native allocations weren't released until objects were disposed, which the code didn't do on an exception path. Fixing disposal with using, limiting rendering concurrency with a SemaphoreSlim, and moving rendering off the request path stopped the kills.
Checks production memory troubleshooting in containerised .NET.
Green flags: distinguishes virtual, committed, working set, GC heap and native memory; explains cgroup kills versus OOM exceptions; knows the GC's container awareness and modes; investigates with counters, gcdumps and dumps; fixes causes before resizing.
Red flags: "increase the limit" or "call GC.Collect"; equates GC heap with process memory; no idea what exit code 137 means.
container limit (cgroup) ---------------------------------------------- 1024 MiB
| GC heap hard limit (default 75%) ---------------------- 768 MiB |
| | gen0 | gen1 | gen2 | LOH | POH | free space | |
| --------------------------------------------------------- |
| native heap (libraries, NativeMemory, unmanaged buffers) |
| thread stacks (threads x up to ~1-1.5 MiB committed as used) |
| JIT code, metadata, runtime data structures |
| page cache for files written by the container |
----------------------------------------------------------------------
exceed the cgroup limit -> kernel OOM killer -> exit code 137
exceed the GC hard limit -> OutOfMemoryException inside the process
Counter (System.Runtime) |
What it tells you |
|---|---|
GC Heap Size |
Managed memory in use |
GC Committed Bytes |
Memory the GC has committed (includes free space) |
Working Set |
Resident memory of the whole process |
Gen 0/1/2 GC Count, % Time in GC |
GC pressure |
Allocation Rate |
How fast garbage is produced |
ThreadPool Thread Count |
Thread growth (stacks) |
LOH Size, POH Size |
Large and pinned objects |
Rule of thumb: working set − GC committed ≈ native + stacks + runtime. If that gap grows, look outside the GC heap.
dotnet-gcdump collect -p <pid> -o before.gcdump
# apply load for 10-30 minutes
dotnet-gcdump collect -p <pid> -o after.gcdump
# open both in Visual Studio (or PerfView) and compare: sort by size diff, then follow paths to root
A gcdump triggers a gen 2 GC and is much smaller than a full dump, so it's safer in production.
| Setting | Effect |
|---|---|
<ServerGarbageCollector>false</ServerGarbageCollector> |
Workstation GC: one heap, less memory, lower throughput on many cores |
<ConcurrentGarbageCollection> |
Background gen 2 collections (default on) |
DOTNET_GCHeapHardLimitPercent / GCHeapHardLimit |
Cap the GC heap |
DOTNET_GCDynamicAdaptationMode=1 |
DATAS (default with Server GC since .NET 9; set to 0 to disable) |
DOTNET_GCHeapCount |
Fix the number of Server GC heaps |
DOTNET_GCConserveMemory |
0–9, trade CPU for less fragmentation |
Change one at a time and measure; the defaults are good for most services.
In regulated industries, memory dumps may contain personal or financial data. Capture them only with approval, store them encrypted in Australian regions, restrict access, and delete them when the investigation closes.
--memory=256m and watch dotnet-counters while load-testing an endpoint that adds to a static list on every request.NativeMemory.Alloc without freeing it, and watch working set grow while the GC heap stays flat.namespace MemoryDiagnostics;
using System.Runtime.InteropServices;
public static class MemoryReport
{
// A cheap diagnostics endpoint payload (protect it; don't expose publicly).
public static object Snapshot()
{
var gc = GC.GetGCMemoryInfo();
using var process = System.Diagnostics.Process.GetCurrentProcess();
return new
{
WorkingSetMb = process.WorkingSet64 / 1_048_576,
PrivateMb = process.PrivateMemorySize64 / 1_048_576,
GcHeapMb = GC.GetTotalMemory(forceFullCollection: false) / 1_048_576,
GcCommittedMb = gc.TotalCommittedBytes / 1_048_576,
GcHardLimitMb = gc.TotalAvailableMemoryBytes / 1_048_576, // reflects the container limit / hard limit
Threads = process.Threads.Count,
ServerGc = System.Runtime.GCSettings.IsServerGC,
};
}
}
// Requires <AllowUnsafeBlocks>true</AllowUnsafeBlocks>.
// Native memory is invisible to the GC: if Dispose isn't called, it leaks until the process dies.
public sealed unsafe class NativeBuffer : IDisposable
{
private void* _pointer;
public NativeBuffer(nuint size)
{
_pointer = NativeMemory.Alloc(size);
Size = size;
}
public nuint Size { get; }
public void Dispose()
{
if (_pointer != null)
{
NativeMemory.Free(_pointer);
_pointer = null;
}
}
}
public static class Renderer
{
private static readonly SemaphoreSlim Concurrency = new(Environment.ProcessorCount);
public static async Task<byte[]> RenderAsync(Func<NativeBuffer, byte[]> render, CancellationToken ct)
{
await Concurrency.WaitAsync(ct); // bound concurrent native work
try
{
using var buffer = new NativeBuffer(16 * 1024 * 1024); // freed even if render throws
return render(buffer);
}
finally
{
Concurrency.Release();
}
}
}
# Live view of the key counters
dotnet-counters monitor -p <pid> --counters System.Runtime[gc-heap-size,gc-committed,working-set,threadpool-thread-count,alloc-rate]
# Confirm an OOM kill
kubectl describe pod <pod> | grep -A3 "Last State"
Mistake: Assuming the GC heap is the process's memory. Why it is wrong: native libraries, thread stacks and runtime data often add hundreds of MB, and the container limit counts all of it. Instead: compare GC heap or committed bytes with working set to see whether the growth is managed or native.
Mistake: Expecting an OutOfMemoryException before a container kill.
Why it is wrong: if native memory pushes the process over the cgroup limit, the kernel kills it with no exception and no application logs.
Instead: monitor working set against the limit, alert at 80-90%, and check exit code 137 and OOMKilled in pod status.
Mistake: "Fixing" OOM kills by raising the limit. Why it is wrong: a leak just takes longer to kill the pod, and you pay for idle memory on every replica. Instead: find the growth with counters and gcdumps or dumps, fix it, then size requests and limits from measurements.
Mistake: Forgetting thread stacks. Why it is wrong: blocking calls make the thread pool inject threads; hundreds of threads add significant committed memory. Instead: watch thread count, remove sync-over-async, and bound concurrency for blocking or native work.
Mistake: Using Server GC with fixed per-core heaps on tiny pods.
Why it is wrong: many heaps and generous budgets on a small pod waste memory or trigger kills.
Instead: keep DATAS enabled (the current default with Server GC), switch to Workstation GC for very small pods, or set GCHeapCount and the hard limit deliberately, and measure.
Mistake: Calling GC.Collect() to control memory.
Why it is wrong: it hides leaks, costs CPU, and does not free native memory.
Instead: fix lifetimes and disposal, and let the GC manage itself with appropriate settings.
Seniors treat memory as a budget across several pools, not one number, and always start by asking which pool is growing.
| Signal | Pool growing | Likely causes | Tool |
|---|---|---|---|
| GC heap and gen 2 rise, never fall | Managed heap | Unbounded caches, static collections, event-handler leaks | dotnet-gcdump diff |
| Working set rises, GC heap flat | Native | Undisposed native handles, native library leak, compression/crypto buffers | Full dump, pmap, code review of IDisposable |
| Thread count rises with load | Stacks | Sync-over-async, blocking I/O, unbounded parallelism | dotnet-counters, dotnet-stack |
| LOH/POH size grows | Large or pinned buffers | Large arrays per request, long-lived pinned buffers | ArrayPool<T>, streaming |
| cgroup usage high, process fine | Page cache | Writing large temp files | Stream to blob storage, delete temp files |
The container limit must cover the GC hard limit plus native memory, stacks and runtime overhead. Seniors choose GC modes for density versus throughput deliberately, keep dashboards of working set against the limit, run soak tests long enough to reveal slow leaks, and handle dumps under privacy obligations (encrypted storage in an Australian region, restricted access, deletion after analysis).
Each thread allocates from its own allocation context, a small chunk of gen 0 where new is just a pointer bump plus zeroing; when it runs out, the thread asks the GC for another chunk, and when the gen 0 budget is used up, a gen 0 GC runs. The GC is generational because most objects die young: gen 0 collections are cheap, survivors are promoted to gen 1 and then gen 2, which is collected rarely (and mostly in the background). Objects of 85,000 bytes or more go to the large object heap (LOH), which is collected only with gen 2 and isn't compacted by default, so it can fragment. Pinned objects can't be moved, which blocks compaction; the pinned object heap (.NET 5+) keeps long-lived pinned buffers separate. In hot paths I reduce the allocation rate rather than tuning the GC.
When I can't attach a debugger, I take a dump: a snapshot of the process's memory, threads and stacks that I analyse offline. For a hang or a leak, dotnet-dump collect -p <pid> does it in seconds. For crashes, I enable automatic crash dumps with the DOTNET_DbgEnableMiniDump environment variables. On App Service I use Diagnose and solve problems, and in Kubernetes dotnet-monitor. Then I analyse with dotnet-dump analyze: clrstack -all and syncblk for hangs, dumpheap -stat and gcroot for leaks, pe for exceptions. Or I open the dump in Visual Studio and use the Threads and Parallel Stacks windows. Dumps contain customer data, so they're handled as sensitive.
First I measure: Application Insights tells me which endpoint is slow and at what percentile, and whether time goes into dependencies like SQL or HTTP. Then I reproduce it on a Release build and run the Visual Studio Performance Profiler (Alt+F2): CPU Usage for hot paths, Database for query count and duration, .NET Object Allocation Tracking for GC pressure, and .NET Async for slow awaits. In an environment where I can't use Visual Studio, dotnet-counters shows live metrics like thread pool queue length and GC, and dotnet-trace captures CPU samples. I fix the biggest cause, often an N+1 query, re-measure against the baseline, and add a load test so it doesn't regress.
How to tell it (STAR)
**Situation:** At [company], our invoice-rendering pods were being OOM-killed at month-end even though the managed heap was only about [300 MB]. **Task:** I needed to stop the crashes before the next billing run. **Action:** I compared GC heap with working set in dotnet-counters and saw the gap growing, which meant native memory. A dump showed thousands of live native renderer handles: we didn't dispose them on the exception path, and synchronous rendering was also inflating the thread count. I fixed disposal, capped concurrency and moved rendering to a background queue. **Result:** Working set settled around [600 MB], there were no more OOM kills, and month-end ran without retries. A two-hour soak test now runs before each release.
Last updated 23 Oct 2026.