A while back I was staring at htop on one of our production servers. A handful of Node.js workers, a couple of Go services, the usual stack. The VIRT column was showing numbers that did not add up. One process alone was showing 18 GB of virtual memory. The machine had 16 GB of RAM. My first thought was that something was seriously wrong.

Nothing was wrong. The OS was doing exactly what it was designed to do. But that moment stuck with me because I realised I had been operating Linux systems for years without being able to explain what was actually happening when a process touched memory. I knew the terms: virtual memory, page table, MMU. I just could not connect them into a coherent picture.

This article is that picture. We will start from the raw problem, work our way through what paging is and why it exists, and then look at how the kernel and CPU work together on every single memory access. At the end there is a section on what this means when you are actually running production systems.


The Problem With Raw Physical Memory#

Physical RAM is a flat array of bytes. If you have 16 GB of RAM, you have roughly 17 billion individually addressable bytes, each with a unique address. No structure, no ownership, just bytes sitting in silicon.

Now imagine running three processes simultaneously. Process A writes some data to address 0x100000. A moment later Process B also writes to 0x100000. Process A’s data is overwritten. There is no concept of ownership in raw physical memory. The address space is shared among everyone. Any process can write anywhere.

Process Awrites to 0x1000Process Bwrites to 0x1000Process Creads from 0x1000Physical RAMflat array of bytesno ownership0x1000 sharedData corruptionNo isolationSecurity breach

Early computing dealt with this. MS-DOS was largely single-tasking precisely because real-mode x86 had no hardware enforcement of memory boundaries. Running multiple programs at once was a recipe for instability. The moment you want a proper multitasking OS with a shell, a database, a web server all running concurrently, raw physical addressing is fundamentally broken.

Something had to change at the architecture level.


Virtual Memory#

The solution the kernel offers is to give every process a private view of memory that does not correspond to physical addresses. This is virtual memory .

Every process on a 64-bit Linux system believes it has the entire address space to itself. In practice, x86-64 hardware uses 48 bits of the address (the full 64-bit space is impractically large, 2^64 bytes is more than a million times the world’s total storage capacity). That gives 2^48 = 256 TB split between user space (lower 128 TB) and kernel space (upper 128 TB). Your process sees addresses from 0x0000000000000000 to 0x00007FFFFFFFFFFF as its own private real estate.

These addresses are not real. They are virtual. When your code reads from 0x7fff1234abcd, the CPU translates that virtual address to a physical one before touching RAM. Your process never knows where in physical memory its data actually lives, and it never knows other processes exist from a memory standpoint.

Process A — Virtual Space0x1000code0x2000dataProcess B — Virtual Space0x1000code0x2000dataPhysical RAMFrame 0xA0000Frame 0xB0000Frame 0xC0000Frame 0xD0000same virtual address, different physical frames

Both processes use virtual address 0x1000 but they map to completely different physical frames. Isolation is enforced at the hardware level before anything reaches RAM.


What is Paging?#

To understand how virtual-to-physical translation works, you need paging. Everything else builds on top of it.

Paging divides both virtual memory and physical memory into fixed-size chunks.

On Linux the standard page size is 4 KB (4096 bytes). Virtual memory is divided into virtual pages. Physical memory is divided into physical frames (also called page frames). A virtual page maps to exactly one physical frame. The kernel maintains the table of these mappings.

Virtual Address SpacePage 0 · 4 KBPage 1 · 4 KBPage 2 · 4 KBPage 3 · 4 KBnot in RAMPhysical RAMFrame 5Frame 2Frame 9Swap on Diskpage evictedmapped

A few things fall directly out of this design:

The kernel tracks memory page by page, not byte by byte. If your process allocates 1 byte, you get a full 4 KB page assigned. This is why RSS in ps or htop is always a multiple of 4096 bytes.

Not every virtual page needs to be in RAM at the same time. A virtual page can be in RAM, swapped to disk, or not yet backed by any physical memory at all. The page table entry for that page records its current state. This is how swap works, and it is how the kernel can satisfy a large malloc() without immediately allocating physical frames (lazy allocation).

Each page has permission bits. Read, write, execute, user/supervisor. If your process tries to write to a read-only page (like a shared library’s code segment), the CPU fires an exception. The kernel catches it and sends SIGSEGV. This hardware enforcement of permissions is what makes memory protection possible, and it falls out naturally from paging without any extra software overhead.

The data structure that stores all the mappings (virtual page to physical frame, plus state and permissions) is called the page table. Every process has its own. The kernel creates it and keeps it updated. The CPU uses it on every memory access.


How Linux Builds the Page Table#

The naive approach: a flat array indexed by virtual page number. For a 48-bit address space with 4 KB pages you need 2^36 entries. At 8 bytes each that is 512 GB per process just for the page table. Not viable.

Linux uses a multi-level page table . Instead of one flat array, it is a tree. Only the parts of the address space that are actually in use get table nodes allocated. A sparse process (most of the 128 TB virtual space unused) has a very small page table. Only the live regions exist as allocated nodes.

On x86-64 Linux, there are four levels. A 48-bit virtual address is split into five fields:

Bits     | Level  | Name                            | Entries
---------|--------|---------------------------------|--------
[47:39]  | L4     | PGD - Page Global Directory     | 512
[38:30]  | L3     | PUD - Page Upper Directory      | 512
[29:21]  | L2     | PMD - Page Middle Directory     | 512
[20:12]  | L1     | PTE - Page Table Entry          | 512
[11:0]   | Offset | Byte offset within the page     | 4096

9 bits per level, 512 entries per table. Each table node is exactly 4 KB, which is exactly one page, convenient for the kernel’s own allocator. Four levels of 512 each covers 2^36 pages, which maps the full 48-bit space.

To resolve any virtual address, you walk the tree from root to leaf:

Resolving a 48-bit Virtual AddressPGD indexbits 47:39PUD indexbits 38:30PMD indexbits 29:21PTE indexbits 20:12Page offsetbits 11:0CR3phys addrof PGDPGD512 entries→ PUD ptrPUD512 entries→ PMD ptrPMD512 entries→ PTE ptrPTE leafframe addr+ flagsFrame4 KB of RAMFinal Physical Addressframe base + page offset

Each entry in PGD, PUD, PMD points to the physical address of the next level table. The leaf PTE entry holds the physical frame address plus a set of flags:

  • Present: page is in RAM right now
  • Writable: writes allowed
  • User: user-mode code can access (kernel pages have this cleared)
  • Dirty: page has been written since last load (used by kernel to decide if it needs to be written back to disk on eviction)
  • Accessed: page has been read or written recently (used by eviction heuristics)
  • NX (No-Execute): instruction fetches from this page are blocked (enforces W^X on data pages)

The CPU’s Role in MMU and CR3 Translation#

Here is where most articles hand-wave: “the OS translates addresses.” That is not quite right. The OS creates and maintains the page tables. The actual translation on every single memory access is done by the CPU in hardware.

The CPU has a dedicated unit called the Memory Management Unit (MMU). It intercepts every memory reference before it hits the memory bus and runs the translation. This includes instruction fetches, data loads, data stores, everything. There is no bypassing it once virtual addressing is enabled.

The CPU knows which page table to use via a register called CR3. CR3 holds the physical address of the current process’s PGD table. When a memory access happens, the MMU reads CR3 and starts the four-level walk through physical memory.

The walk takes four memory reads, one per level. That is expensive to do on every access. So the CPU caches recent translations in a small, fast on-chip cache called the TLB (Translation Lookaside Buffer). Most accesses hit the TLB and translate in roughly one cycle. A TLB miss falls back to the full hardware page table walk.

Processissues accessMMUin the CPUTLB Cacherecent translationshit ≈ 1 cyclePage Table WalkCR3 → PGD → ... → PTE4 memory readsPhysicaladdress readyRAMread / write1 VA2 lookupHITMISSphys addr+ fill TLBdata returned to process

Page Faults

If the MMU walks the page table and hits a PTE with the present bit cleared, it fires a page fault exception. Control transfers to the kernel’s page fault handler. The handler checks why the page is not present:

  • Page is in swap: read it from disk, update PTE present bit, resume the process
  • Page was never allocated: allocate a fresh frame, zero it out, update PTE, resume
  • Access is genuinely illegal: wrong permissions, out of mapped range. Kernel sends SIGSEGV.

In the first two cases the process resumes as if nothing happened. It has no idea a disk read occurred. From its perspective the memory was always there.


Context Switching and CR3#

When the kernel switches the CPU from process A to process B, it needs to switch the entire address space. It does this by writing process B’s PGD address into CR3.

Kernel Schedulerdecides: switch Process A → Process BSave Process A stateregisters saved, including current CR3CR3_A storedLoad CR3 with Process B page tableone register write switches the entireaddress spaceTLB invalidationflush stale entries, or retain themusing PCID tags (modern x86)Process B address space activeCPU now resolves B's virtual addressesCR3 registerCR3_ACR3_B

On older hardware every context switch triggered a full TLB flush. Every cached translation from process A was invalidated because the entries had no process tag. Process B started with a cold TLB, paying the page table walk cost on its first accesses.

Modern x86 CPUs support PCID (Process-Context Identifiers), a 12-bit tag on each TLB entry. When the kernel restores CR3 for process B, it can keep valid entries from other processes in the TLB and simply start tagging new entries with B’s PCID. Returning to process A later can reuse its still-valid TLB entries.

KPTI (Kernel Page Table Isolation), introduced as the Meltdown mitigation in early 2018, added cost here. With KPTI, user space and kernel space use separate page tables, requiring a CR3 switch on every syscall entry and exit. Every read(), every write(), every epoll_wait() crosses a CR3 boundary twice. This is why Meltdown patches had measurable throughput impact on syscall-heavy workloads.


What This Architecture Unlocks ?#

Swap

When physical RAM is under pressure, the kernel can evict a page by writing it to the swap partition and clearing its PTE present bit. The next access to that virtual address fires a page fault, the kernel reads the page back from disk, sets the present bit again, and resumes. The process never knew. This is how Linux can run more total allocated memory than you have physical RAM, at the cost of latency when cold pages are fetched.

Copy-on-Write

When you call fork(), the kernel does not copy all the parent’s memory. That would be prohibitively expensive for a process with several gigabytes of RSS. Instead, both parent and child get page tables pointing to the same physical frames. All those shared PTEs are marked read-only. When either process writes to a page, the MMU fires a fault (write to a read-only page). The kernel catches it, copies that specific 4 KB page for the writing process, marks the copy writable, updates the PTE, and resumes. Only the pages that actually diverge ever get copied.

This is copy-on-write. Most forked processes immediately call exec(). With COW, fork() overhead is proportional to page table size, not RSS. This matters enormously for shell scripts spawning hundreds of subprocesses.

The same mechanism serves shared libraries. libc is mapped into one set of physical frames. Every process that links against it has its PTEs for the libc region pointing to those same frames. Code pages are read-only so COW never fires. One copy in RAM serves all running processes.

Memory Protection

PTE supervisor bits mean user-mode code cannot access kernel memory pages. Attempting a read from a kernel address from userspace fires a page fault, the kernel sees the illegal access, and sends SIGSEGV to the process. No software check is involved. The MMU enforces it on every single access.


What This Means When You Operate Linux Systems#

Virtual Size vs RSS vs PSS

That 18 GB VIRT I saw in htop was not a memory leak. VIRT (VSZ) is the size of the virtual address space mapped by the process, including regions that have no physical backing yet: mmapped files, reserved stack space, shared library address ranges. Most of it was not in RAM.

RSS is the count of pages currently resident in physical RAM. That is what matters for memory pressure. PSS (Proportional Set Size) is even more accurate because it divides shared pages proportionally among all processes that map them.

# VSZ vs RSS
ps -o pid,vsz,rss,comm -p <pid>

# PSS is the honest number for shared-memory-heavy processes
cat /proc/<pid>/smaps_rollup | grep Pss

# Per-mapping breakdown
cat /proc/<pid>/smaps

Memory Overcommit

Linux allows processes to commit more virtual memory than RAM plus swap can back. malloc() succeeds immediately because physical frames are only assigned when pages are actually written. This is controlled by vm.overcommit_memory:

# 0 = heuristic (default): allows overcommit up to a point
# 1 = always allow: malloc never returns NULL regardless of RAM state
# 2 = strict: total commit bounded by swap + overcommit_ratio percent of RAM
cat /proc/sys/vm/overcommit_memory

# For databases and critical services where OOM kill is worse than allocation failure
echo 2 > /proc/sys/vm/overcommit_memory
sysctl vm.overcommit_ratio=80  # allow committing up to 80% of RAM + all swap

OOM Killer

When physical RAM is exhausted and swap is full, the kernel’s OOM killer fires. It scores all processes based on memory usage plus heuristics and kills the highest scorer. You can influence the scoring:

# Protect a critical process (range is -1000 to 1000)
echo -1000 > /proc/<pid>/oom_score_adj

# Make a process the preferred OOM target
echo 1000 > /proc/<pid>/oom_score_adj

# Check current calculated score
cat /proc/<pid>/oom_score

# Watch for OOM kill events
dmesg -T | grep -i "oom\|killed process"

If your service is getting OOM killed on a machine that still shows free VIRT, check RSS and PSS. Also check if another process is the actual memory hog and your service is just collateral damage based on oom_score.

Huge Pages and TLB Pressure

Default page size is 4 KB. A process with 10 GB RSS has around 2.5 million resident pages, each potentially needing a TLB entry. Modern CPUs have L1 TLBs of a few hundred entries and L2 TLBs of a few thousand. With a large working set you will miss the TLB constantly, paying the full four-level page table walk cost on every miss.

Huge pages (2 MB) cover 512x more memory per TLB entry. For workloads with a large, frequently accessed working set, this can meaningfully reduce latency.

# Check THP (Transparent Huge Pages) status
cat /sys/kernel/mm/transparent_hugepage/enabled

# Disable THP for latency-sensitive services
# Redis and Kafka both recommend this - THP defragmentation causes latency spikes
echo never > /sys/kernel/mm/transparent_hugepage/enabled

# Reserve explicit 2 MB huge pages upfront (avoids defragmentation cost)
echo 512 > /proc/sys/vm/nr_hugepages

# Check allocation
grep -i huge /proc/meminfo

# Measure TLB pressure with perf
perf stat -e dTLB-load-misses,iTLB-load-misses,dTLB-loads -p <pid> sleep 10

THP allocates huge pages transparently but needs to find 512 contiguous 4 KB frames to form a 2 MB huge page. On a fragmented system this triggers memory compaction, which can stall allocation for tens of milliseconds. Redis latency spikes of 50-200ms on high-memory systems are commonly traced back to THP compaction. Disable it and pre-reserve explicit huge pages if the workload justifies it.

Reading /proc/meminfo Correctly

MemTotal:        65536000 kB   # total physical RAM
MemFree:          2345000 kB   # completely unused frames
MemAvailable:    40123000 kB   # what processes can actually use
                               # = MemFree + reclaimable cache
Cached:          28000000 kB   # page cache (file data, reclaimable)
SwapTotal:        8388608 kB
SwapUsed:          512000 kB   # non-zero = you have memory pressure
AnonPages:       18000000 kB   # anonymous pages (heap, stack, mmap'd)
Mapped:           3200000 kB   # pages mapped into at least one process
HugePages_Total:      512      # pre-reserved 2 MB huge pages
HugePages_Free:       312      # available huge pages

Alert on MemAvailable going low, not MemFree. MemFree being low just means the kernel has filled the page cache with file data, which is expected and fine. MemAvailable being low means processes are competing for physical frames.

Containers and cgroups v2

Containers do not change the virtual memory model. Each container process has its own virtual address space and its own page table, exactly like any other process. What cgroups v2 adds is a hard cap on the number of physical frames that group of processes can have resident:

# Set memory limit for a cgroup
echo "512M" > /sys/fs/cgroup/myservice/memory.max

# Current physical memory used by the cgroup
cat /sys/fs/cgroup/myservice/memory.current

# Swap limit (cgroups v2 tracks them separately)
echo "256M" > /sys/fs/cgroup/myservice/memory.swap.max

When a container hits memory.max, the kernel tries to reclaim pages from that cgroup first (evict page cache, swap anonymous pages) before firing an OOM kill scoped to that cgroup. The virtual address space of processes inside the container is still technically unlimited unless you set RLIMIT_AS explicitly.


Virtual memory is one of those abstractions that is completely invisible when things are working. Your process gets its pages, the kernel handles swapping, shared libraries load once for everyone, and the MMU does its four-level page table walk on every single instruction fetch and every data access, thousands of times per millisecond, without you ever thinking about it.

When it breaks down, knowing what is actually happening at the page table level gives you something most runbooks do not: an understanding of why, not just what command to run. OOM kills on a machine still showing high VIRT, latency spikes from THP compaction, throughput degradation after Meltdown patches, TLB pressure in a database under load. All of these trace back to the mechanics covered here.


Further reading

What Every Programmer Should Know About Memory.pdf912 KB

The kernel never lets your process touch real memory because the one time it did, nothing worked reliably. The private address space it hands every process is one of the best design decisions in operating systems history.