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

📚 Interview question

Explain virtual memory and paging. How does the operating system map a process's virtual addresses to physical RAM?

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.

Mid-Level Theory Intermediate memory-mapped-files operating-system page-tables paging tlb virtual-memory

🎯 Key points

  • Each process gets its own virtual address space; the MMU translates virtual pages to physical frames using per-process page tables (four or five levels on x64 and Arm64) that the OS maintains.
  • The TLB caches recent translations. A TLB miss costs a page-table walk, so large pages (2 MB, and 1 GB on x64) raise TLB reach for big heaps and are worth knowing for memory-heavy services.
  • Reserve, commit and resident are different: reserving claims address range, committing charges the commit limit or overcommit accounting, and pages only become resident (working set) when first touched.
  • Demand paging means the first touch of a page raises a page fault. Minor (soft) faults map an existing frame; major (hard) faults read from disk or swap and are thousands of times slower.
  • Copy-on-write lets fork, shared libraries and mapped files share frames until a write, which is why RSS overcounts and PSS or private bytes is a better per-process measure.
  • Memory-mapped files (MemoryMappedFile in .NET) map a file into the address space so the page cache does the I/O; they suit large read-mostly data and inter-process sharing.
  • When physical RAM runs short the OS evicts or swaps pages; sustained major faults mean thrashing, and in containers the cgroup memory limit triggers the OOM killer instead.
  • Virtual memory also enforces isolation and security: per-page permissions, NX (no-execute) and ASLR randomise layout, and a stray pointer faults instead of corrupting another process.

⏱ 30-second answer

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.

🗣 Full answer (senior)

Why virtual memory

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)

Pages, frames and page tables

  • The virtual address space is divided into pages; physical RAM into frames of the same size (4 KB normally; 2 MB or 1 GB large pages).
  • A page table entry holds the frame number plus flags: present, writable, executable, user/kernel, accessed, dirty.
  • On x64, page tables are a four-level tree (five-level on some newer systems), so a full translation can take several memory reads.
  • The TLB caches recent translations so most accesses need none of that.

Lifecycle of memory in a process

  1. Reserve: claim a range of addresses (no physical memory, no commit charge). The .NET GC reserves large ranges up front.
  2. Commit: promise backing (RAM or page file) for part of that range; counts against the system's commit limit (Windows) or overcommit accounting (Linux).
  3. Touch: the first access to a committed page triggers a page fault; the OS finds a free frame (zeroed) and maps it. Now it counts in the working set.
  4. Trim or page out: under memory pressure, the OS may remove the page from the working set, writing it to the page file or swap if it's dirty.
  5. Release: the process frees it.

Special mappings

  • Memory-mapped files (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.
  • Copy-on-write: after fork (Linux) or when sharing pages, both processes see the same frames until one writes; then the OS copies just that page.
  • Executable pages: JIT-compiled code is written into pages that are then made executable; W^X policies avoid pages being writable and executable at once.

Swap, page files and containers

  • Windows uses a page file; Linux uses swap (often disabled on Kubernetes nodes by default).
  • When working sets exceed RAM and pages must be read back from disk, performance collapses (thrashing).
  • In containers without swap, exceeding the memory limit gets the process killed rather than paged.

Production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Address translation, drawn (x64, 4 KB pages)

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

2. Reserved, committed, resident

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.

3. Large pages

  • A 2 MB page covers 512 times more memory per TLB entry, reducing TLB misses for big heaps.
  • Requires OS configuration and privileges; the .NET GC can use large pages with GCLargePages together with a heap hard limit. It's specialised tuning for very large, long-running servers.
  • Linux's transparent huge pages (THP) can apply automatically, with mixed results for some workloads.

4. Overcommit on Linux

  • By default, Linux lets processes commit more than RAM plus swap (heuristic overcommit). Failure comes later, at touch time, via the OOM killer.
  • Windows enforces a commit limit (RAM plus page file); allocation fails up front with an out-of-memory error.

5. ASLR and security

  • Address space layout randomisation places code, stacks and heaps at random addresses each run, making exploits harder.
  • Non-executable stacks and heaps (NX/DEP) stop injected data being run as code.
  • .NET code benefits automatically; native libraries should be built with these protections too.

6. Memory-mapped files in .NET

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.

7. Try it yourself

  1. Allocate a 1 GB 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).
  2. Memory-map a large file, read one byte from every page, and watch resident memory and page faults (ps -o min_flt,maj_flt -p <pid>).
  3. Compare Process.VirtualMemorySize64, PrivateMemorySize64 and WorkingSet64 for an idle ASP.NET Core app.

💻 Code

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");
    }
}

⚠ Common mistakes

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says a page might not be in RAM Why: Probes page faults and their latency cost
    How to answer

    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:

    • Soft (minor) fault: the page is already in RAM (file cache, shared library, a freshly zeroed page, copy-on-write) and only the page table needs updating. Costs microseconds.
    • Hard (major) fault: the page must be read from disk (swap file, page file or a memory-mapped file). Costs milliseconds, around 1,000 times slower.
    • Invalid access: no mapping at all, so the process gets an access violation / SIGSEGV.

    Minor faults are normal. A burst of major faults means the working set does not fit in RAM, and latency spikes follow.

  2. When: Candidate mentions working set or RSS Why: Connects virtual memory to container memory limits and OOMKilled
    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.

↩ Usually asked after

More in Operating System

Last updated 23 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me