Why virtual memory exists, pages and page frames, multi-level page tables, the TLB, demand paging, copy-on-write, memory-mapped files, swap and the page file, overcommit, address space layout randomisation, and what all this means for .NET processes.
Virtual memory gives every process its own large, private address space, so programs don't see each other's memory or need to know where physical RAM is. Memory is managed in pages (usually 4 KB). The OS keeps a page table per process that maps virtual pages to physical frames; the CPU's MMU translates every access, and the TLB caches recent translations. Pages get physical memory only when first touched (demand paging), can be shared (shared libraries, copy-on-write), can be backed by files (memory-mapped files), and can be pushed to swap or the page file under pressure. For .NET this explains why reserved and committed memory differ, why the GC reserves big ranges cheaply, and why working set is what really matters.
| Benefit | How |
|---|---|
| Isolation | Each process has its own mappings; it can't address another's memory |
| Simplicity | Every process sees a contiguous address space, regardless of where frames are in RAM |
| Efficiency | Pages are allocated on demand; unused reservations cost nothing physical |
| Sharing | The same physical page (for example a shared library's code) can be mapped into many processes |
| Overcommit and paging | The total of all processes' memory can exceed RAM, with the OS moving cold pages to disk |
| Security | Page permissions (read, write, execute) and address space layout randomisation (ASLR) |
MemoryMappedFile in .NET): file contents appear as memory; reads fault pages in from the page cache, writes mark pages dirty to be flushed later. Great for large read-mostly files and inter-process sharing.fork (Linux) or when sharing pages, both processes see the same frames until one writes; then the OS copies just that page.A search service loaded a 6 GB index into byte arrays at start-up, needing 6 GB of committed memory per pod and a slow start. Switching to a memory-mapped file let the OS page in only the parts actually used, shared the page cache between two processes on the same node, and cut start-up from minutes to seconds. The trade-off was that the first queries touching cold regions were slower (page faults), which the team handled with a warm-up step.
Checks a correct mental model of memory management at the OS level.
Green flags: explains pages, page tables, the MMU and TLB, demand paging, reserve versus commit versus resident, memory-mapped files, swap and thrashing, and relates it to .NET metrics.
Red flags: equates virtual memory with swap; thinks allocation immediately consumes RAM; uses virtual size to size containers.
virtual address (48 bits used)
| PML4 index | PDPT index | PD index | PT index | offset (12 bits) |
| | | |
v v v v
[PML4] ---> [PDPT] ---> [ PD ] ---> [ PT ] ---> frame number + offset = physical address
CR3 register points to the PML4 of the current process
TLB hit: translation in ~1 cycle
TLB miss: up to 4 memory reads (often cached) for the page walk
| State | Physical RAM used? | Counted where |
|---|---|---|
| Reserved | No | Virtual size only |
| Committed, not touched | No (Linux) / charged to commit limit (Windows) | Private bytes / commit charge |
| Committed and touched | Yes | Working set / RSS |
| Paged out | No, on disk | Commit, not working set |
That's why a .NET process can show a virtual size of hundreds of GB (GC reservations) while using a few hundred MB of RAM. Size services by working set and committed memory, not virtual size.
GCLargePages together with a heap hard limit. It's specialised tuning for very large, long-running servers.using var mmf = MemoryMappedFile.CreateFromFile("index.bin", FileMode.Open, null, 0, MemoryMappedFileAccess.Read);
using var view = mmf.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read);
long value = view.ReadInt64(offset); // page fault on first touch of each page, then RAM speed
Good for large read-mostly data and sharing between processes; less suitable for heavy random writes or data that must be durable immediately.
byte[] and watch working set before and after writing one byte per 4 KB page (on Linux the GC may commit and zero memory differently; observe both virtual and resident sizes in top).ps -o min_flt,maj_flt -p <pid>).Process.VirtualMemorySize64, PrivateMemorySize64 and WorkingSet64 for an idle ASP.NET Core app.namespace VirtualMemory;
using System.Diagnostics;
using System.IO.MemoryMappedFiles;
public static class Program
{
private const int PageSize = 4096;
public static void Main(string[] args)
{
Report("Start");
var big = GC.AllocateUninitializedArray<byte>(512 * 1024 * 1024); // address space committed, pages not all touched
Report("After allocating 512 MB (uninitialised)");
for (var i = 0; i < big.Length; i += PageSize)
{
big[i] = 1; // first touch of each page -> page fault -> RAM
}
Report("After touching every page");
GC.KeepAlive(big);
if (args.Length > 0)
{
using var mmf = MemoryMappedFile.CreateFromFile(args[0], FileMode.Open, null, 0, MemoryMappedFileAccess.Read);
using var view = mmf.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read);
long sum = 0;
for (long offset = 0; offset < view.Capacity; offset += PageSize)
{
sum += view.ReadByte(offset); // pages come from the page cache on demand
}
Report($"After reading one byte per page of {args[0]} (sum {sum})");
}
}
private static void Report(string label)
{
using var p = Process.GetCurrentProcess();
Console.WriteLine($"{label,-45} virtual {p.VirtualMemorySize64 >> 20,8} MB private {p.PrivateMemorySize64 >> 20,6} MB working set {p.WorkingSet64 >> 20,6} MB");
}
}
Mistake: Treating virtual size (VSZ or Virtual Bytes) as memory use.
Why it is wrong: The .NET runtime and other native code reserve large address ranges they never commit or touch, so virtual size can be many gigabytes on a healthy process.
Instead: Watch working set or RSS, private bytes (committed private memory), and the GC heap size from dotnet-counters.
Mistake: Assuming every page fault is bad.
Why it is wrong: Minor faults are a normal part of demand paging and copy-on-write and cost microseconds.
Instead: Separate minor from major faults (ps -o min_flt,maj_flt, /proc/<pid>/stat, or Windows Hard Faults/sec) and only worry about sustained major faults.
Mistake: Thinking swap makes memory limits safe in containers. Why it is wrong: Many Kubernetes nodes run without swap, and a cgroup limit breach kills the process rather than slowing it down. Instead: Size the container limit with headroom over the GC heap plus native memory, and let the GC see the limit (it uses 75% of it by default for the heap hard limit).
Mistake: Turning on large pages everywhere for speed.
Why it is wrong: Large pages must be physically contiguous, can fragment or fail to allocate, and on Windows need the Lock Pages in Memory privilege; transparent huge pages on Linux can cause latency spikes during compaction.
Instead: Measure TLB misses first, then enable large pages (GCLargePages with a hard limit) only for big, long-lived heaps where the gain is proven.
Mistake: Believing each process sees physical addresses or can read another process's memory by pointer. Why it is wrong: Every address a process uses is virtual and is translated through its own page tables. Instead: Explain that sharing happens only through explicit shared mappings, such as memory-mapped files or shared memory sections.
A senior answer links paging to the numbers you actually see in production and to the trade-offs you can tune.
| Choice | Benefit | Cost or risk | When to use it |
|---|---|---|---|
| 4 KB pages (default) | Fine-grained, low waste, easy to allocate | More TLB misses on large heaps | Almost all services |
| 2 MB or 1 GB large pages | Far higher TLB reach, fewer page walks | Contiguous memory needed, privilege on Windows, possible allocation failure | Big long-lived heaps after measuring TLB misses |
| Memory-mapped files | Page cache does the I/O, zero-copy sharing between processes | Page faults show up as latency, harder error handling on I/O failure | Large read-mostly data, IPC |
| Swap enabled | Survives short memory spikes | Thrashing turns into latency instead of a clean failure | Desktops and some VMs, rarely containers |
| Strict container limits, no swap | Predictable, fast failure (OOM kill) | Need accurate sizing and headroom | Kubernetes services |
Monitoring: use dotnet-counters (working set, GC heap size, committed bytes), dotnet-gcdump and PerfView for what is in the heap, ps, smem or /proc/<pid>/smaps_rollup for RSS and PSS on Linux, and Process Explorer or Performance Monitor (Private Bytes, Hard Faults/sec) on Windows. perf stat -e dTLB-load-misses tells you whether large pages would help.
Native dependencies: native libraries loaded into your process share its address space, so a bug in them can corrupt managed memory. Keep them patched, prefer signed packages from trusted feeds, and rely on ASLR and NX rather than disabling them for compatibility.
A page fault is a CPU exception raised when code touches a virtual address whose page is not currently mapped to physical RAM for that process. The kernel handles it:
Minor faults are normal. A burst of major faults means the working set does not fit in RAM, and latency spikes follow.
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.
Last updated 23 Oct 2026.