Here is a puzzle that a good interviewer might open with. A program stores the number 100 at some address, forks, and the child changes the value to 999. Both processes then print the address and the value. The addresses are identical. The values are not. If you can explain why, you understand virtual memory; if you can’t, the rest of the memory questions (paging, page faults, copy-on-write, thrashing) are going to feel like trivia.
Same address, different memory
This is that experiment, run with Python 3.14 on an M1 Mac. ctypes gives a fixed C long whose address we can print:
import ctypes, os
counter = ctypes.c_long(100)
addr = ctypes.addressof(counter)
pid = os.fork()
if pid == 0:
counter.value = 999
print(f"child pid={os.getpid()} address={addr:#x} value={counter.value}", flush=True)
os._exit(0)
os.waitpid(pid, 0)
print(f"parent pid={os.getpid()} address={addr:#x} value={counter.value}", flush=True)child pid=10733 address=0x107741998 value=999
parent pid=10731 address=0x107741998 value=100The number 0x107741998 is not a location in RAM. It’s a virtual address, meaningful only inside one process. Each process has its own table describing where its virtual pages really live, and after the child’s write the two tables point that page at different physical memory. Nothing in the child’s address space can even name the parent’s copy.
Run the script again and you’ll get a different address (an earlier run printed 0x1055a9998). That’s address space layout randomisation: the kernel places the heap, stack and libraries at random offsets on each launch so an attacker can’t hard-code addresses. It’s only possible because the addresses were never physical in the first place.
How the hardware translates an address
Memory is managed in fixed-size pages. Most x86-64 systems use 4 KiB pages, and Apple Silicon uses 16 KiB, which vm_stat reports in its first line: Mach Virtual Memory Statistics: (page size of 16384 bytes). A virtual address splits into two parts: the virtual page number and the offset within the page. With 16 KiB pages the offset is the low 14 bits:
virtual address 0x107741998
offset (low 14) 0x1998 same in the physical address
page number 0x41dd0 looked up in the page tablesOn every load, store and instruction fetch, the CPU’s memory management unit (MMU) looks up the page number in the current process’s page tables and gets back a physical frame number plus permission bits. It glues the frame number to the unchanged offset and that’s the physical address. The permission bits say whether the page is readable, writable, executable and accessible from user mode. If the entry is missing or the access isn’t allowed, the MMU raises a page fault and the kernel decides what to do: allocate a page, read it from disk, copy it, or send the process SIGSEGV.
A single flat table would be enormous for a 48-bit address space, so page tables are trees. x86-64 Linux walks four levels for 48-bit addresses. A walk on every access would be ruinous, so the CPU caches recent translations in the translation lookaside buffer (TLB). Hits cost nothing noticeable; misses cost a walk through memory. Larger pages help here, since each TLB entry covers more memory, which is one reason databases like huge pages.
The kernel tells the MMU which tree to use through a register (CR3 on x86, TTBR0_EL1 on ARM64). Switching processes means loading a different value into it, and that single change is what swaps the entire visible world from one process to the next. User code can’t write that register; trying to even read it from user mode gets the process killed with SIGILL.
What isolation buys and what sharing still happens
Isolation comes for free from translation. Process A can’t read process B’s memory because no entry in A’s tables points at B’s frames. A wild pointer in A either hits A’s own memory or faults. This is a much stronger guarantee than “programs promise to behave”, and it’s why one crashing browser tab doesn’t corrupt the others.
The kernel still shares physical memory on purpose when it’s safe:
- Shared libraries. The read-only code of libc is mapped into every process, often at the same physical frames.
- Copy-on-write. After
fork, parent and child point at the same frames marked read-only. The first write faults and the kernel copies just that page, which is exactly what happened to ourcounter. - Explicit shared mappings.
mmapwithMAP_SHAREDor a POSIX shared memory object lets two processes map the same frames, each possibly at a different virtual address.
The misconception interviewers listen for is “virtual memory means using the disk as extra RAM”. That’s swap (or paging to disk). Virtual memory is the translation layer. It makes swap possible, along with lazy allocation, memory-mapped files, copy-on-write and isolation, but a phone with no swap file still uses virtual memory for every access.
Reserving address space is not using memory
Because pages get physical frames only when touched, a program can reserve far more address space than the machine has RAM. This test maps 32 GiB of anonymous memory on an 8 GiB M1, touches only the first 200 MiB, and checks ps after each step:
m = mmap.mmap(-1, 32 << 30) # 32 GiB, anonymous
for off in range(0, 200 << 20, mmap.PAGESIZE): # touch the first 200 MiB
m[off] = 1start rss=16 MiB vsz=425066 MiB
after 32 GiB map rss=16 MiB vsz=457834 MiB
touched 200 MiB rss=215 MiB vsz=457834 MiB
after munmap rss=15 MiB vsz=425066 MiBThe mapping grew the virtual size (VSZ) by exactly 32,768 MiB and resident memory (RSS) by nothing. Touching 200 MiB raised RSS by about 200 MiB, one page at a time. Notice also that an idle Python process already had a VSZ of about 415 GiB: macOS reserves large ranges for the shared library cache and other regions that are never backed by RAM. VSZ tells you how much address space is reserved, which is almost never the number you care about.
On Linux the same trick works, subject to the overcommit policy in /proc/sys/vm/overcommit_memory (I didn’t run the Linux version). The catch is that promises can exceed supply. If processes then touch more pages than RAM plus swap can hold, Linux’s OOM killer picks a process to terminate, while macOS compresses memory and swaps before it gets that far.
Interview exercise: 12 GB of VSZ in a 2 GiB container
A Java service in a container with a 2 GiB memory limit shows a VSZ of 12 GB in ps. An engineer wants to raise the limit to 16 GB “to be safe”. What do you check before agreeing?
Answer
VSZ counts reserved address space, not memory in use, so 12 GB on its own proves nothing. The JVM reserves address space for the maximum heap, for thread stacks (each thread reserves its stack size up front), for code caches and for memory-mapped files, and most of that may never be touched. On macOS the 32 GiB mapping above raised VSZ by 32 GiB and RSS by zero.
What matters to the container’s limit is memory actually charged to the cgroup: resident anonymous memory plus page cache the container uses. I’d look at RSS, at the cgroup’s own accounting (memory.current and memory.stat under cgroup v2), and at the JVM’s native memory tracking to split heap from metaspace, thread stacks and direct buffers. If RSS sits around 1.4 GB and is stable, the limit is fine. If it’s creeping toward 2 GB, find what’s growing. A 16 GB limit would hide a leak, not fix it.
Related memory notes
- Operating Systems interview questions
- Operating Systems MCQs
- Copy-on-Write Memory and fork
- User Mode, Kernel Mode and Privileged Operations
- OSTEP, The Abstraction: Address Spaces, with later chapters on paging and TLBs