Skip to content
Software Atlas

Virtual Memory

Every address your program uses is a fake one. How the CPU and the OS translate them: pages and frames, page tables, the TLB, page faults, mmap, VSZ vs RSS, overcommit and the OOM killer, measured from Go and Node.

⏱ ~45 min read14 sections5 interactive widgetsGo · JavaScript · Linux
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceEvery memory address your program uses is a made-up (virtual) number. On every access, the CPU translates it into a real spot in RAM, one 4 KiB (4,096-byte) page at a time, using a map the OS keeps for each process.

In computer terms: the key card numbers are virtual addresses, the real rooms are slots in RAM called page frames, and the front desk's map is the page table. The checking happens in hardware, inside the CPU, on every single memory read and write (a load or store).

Two processes, one RAM
Process A (virtual)Physical RAM (frames)Process B (virtual)page 0 · never mappedpage 1 · codepage 2 · libc (shared)page 3 · heappage 4 · heappage 5 · heap, untouchedpage 6 · heap (old)page 7 · stackpage 0 · never mappedpage 1 · codepage 2 · libc (shared)page 3 · heappage 4 · heap, untouchedpage 5 · heap, untouchedpage 6 · stackpage 7 · stack, untouchedframe 0 · Aframe 1 · Bframe 2 · A + Bframe 3 · Aframe 4 · Bframe 5 · Aframe 6 · Aframe 7 · Bswap (on disk)
Hover a page. Both processes have a "page 1", "page 2" and so on; the page tables send them to different frames, except the shared library (amber), which both map to the same frame. Dashed pages are reserved but have no RAM yet; one of A's pages has been moved out to swap on disk.

The four things it buys you

1

Isolation

Process A's addresses can only lead to A's frames. A bug or an attacker in one process can't read or scribble on another's memory.
2

A big, tidy, private space

Each process sees one huge, contiguous range starting from the same layout, no matter how chopped up the real RAM is.
3

Sharing and laziness

Two processes can map the same frame (shared code, files). And RAM is only handed out when a page is first touched.

The fourth one surprises people: because RAM is handed out lazily, programs can reserve far more memory than they use, and in total even more than the machine has (§09 reserves 8 GiB and uses 11 MB). Even a tiny Go "hello world" on the machine used for this article reported this in /proc/self/status, a file where Linux reports a process's memory stats:

/proc/self/status of a Go hello-world (real output)
VmSize:	 1225224 kB    ← address space reserved: about 1.2 GB
VmRSS:	    1920 kB    ← actually in RAM: about 2 MB

Both numbers are true. One counts rooms booked, the other counts rooms with someone in them. By the end of §07 you'll know which one to put on a dashboard.

What you'll learn

  • §02–§04: how a virtual address becomes a physical one (pages, page tables, the TLB), with a translator you can drive yourself.
  • §05–§06: page faults (how memory appears on first touch) and mmap (files as memory), measured from Go and Node.
  • §07–§09: the production side: VSZ vs RSS, why memory doesn't drop after garbage collection, overcommit, swap and the OOM killer.
  • §10–§11: staff-level trade-offs: mmap in databases, huge pages, NUMA, ASLR and Meltdown.
Foundations§02

Pages and frames: memory in 4 KiB chunks

In one sentenceThe OS doesn't map memory byte by byte. It maps pages: fixed 4 KiB chunks. An address is just "which page" + "how far into it".

Why chunks?

Suppose the OS kept a map entry for every single byte: "virtual byte 1000 lives at physical byte 73,512". Each entry needs about 8 bytes to store a physical address. So describing 1 GB of memory would take 8 GB of map. The map would be bigger than the memory it describes.

So memory is handled in pages instead: blocks of 4096 bytes (4 KiB; 1 KiB = 1024 bytes). One map entry covers a whole page. Now 1 GB of memory needs about 262,000 entries, around 2 MB of map. That's affordable.

  • A page is a 4 KiB chunk of virtual memory: what your program sees.
  • A Page frame (or just "frame") is a 4 KiB slot of physical RAM: where the bytes really are.
  • The Page table says which frame each page lives in, plus what you're allowed to do with it (read, write, run as code).

Splitting an address

4096 is exactly 212, so the position inside a page always fits in the lowest 12 bits of the address. That gives a beautifully simple rule:

  • the low 12 bits are the offset (0 to 4095): where in the page;
  • everything above is the page number: which page.

In Hexadecimal, each digit is exactly 4 bits, so 12 bits = the last three hex digits. You can split an address by eye.

Worked example. Take the 16-bit address 0x5A3C:

hexbinary
whole address0x5A3C0101 1010 0011 1100
page number (top 4 bits)0x50101
offset (low 12 bits)0xA3C1010 0011 1100

Now look page 5 up in the page table. Say it lives in frame 0x11. Translation swaps the page number for the frame number and keeps the offset: physical address = 0x11 × 0x1000 + 0xA3C = 0x11A3C. Multiplying by 0x1000 just appends three zeros in hex (0x11000), so the result is simply the frame digits followed by the offset digits.

Drive it yourself. Try the presets too: reading code is fine but writing it isn't, a "first touch" of reserved memory triggers a fault that the kernel (the core of the OS, which runs with full rights) quietly fixes, and a null pointer lands on page 0, which is never mapped.

Address translator
A toy machine with 16-bit addresses: 16 pages of 4 KiB. Type an address or pick a preset, then read or write.
Virtual address 0x5A3C16 bits
0101101000111100
■ page number (4 bits) = 0x5■ offset (12 bits) = 0xA3C
With 4 KiB pages the offset is always the last 3 hex digits, because 4096 = 0x1000.
Translated0x11A3C
11A3Cframe · offset
Page 0x5 → frame 0x11. The offset 0xA3C is copied unchanged. Physical address = frame × 0x1000 + offset = 0x11A3C.
Why 5 hex digits from a 4-digit address? Physical addresses can be wider than virtual ones: this toy machine has more RAM (64 frames) than one process's 16 pages can cover.
Page tablepage → frame · perms
0x0—
0x1→ 0x2F r-x
0x2→ 0x30 r--
0x3→ 0x07 rw-
0x4→ 0x1A rw-
0x5→ 0x11 rw-
0x6→ 0x3C rw-
0x7not yet
0x8not yet
0x9—
0xA—
0xB—
0xC—
0xDnot yet
0xE→ 0x22 rw-
0xF→ 0x09 rw-
"not yet" = reserved, no frame. "—" = nothing mapped. Hover a row to see what lives there.

Who does the translating?

Not your program, and not the OS on each access. It's a piece of the CPU called the MMU. It translates every load and store in hardware, and checks the permission bits at the same time. The OS's job is to fill in the page tables, and to step in when the MMU raises a Page fault because an entry is missing or forbids the access.

That's also where the Segmentation fault you know comes from. A null pointer is address 0, which is on page 0. The OS deliberately never maps page 0. So the MMU faults, the kernel sees there's nothing to fix, and it kills the process.

Seeing it from Go

Go lets you print a variable's virtual address. Here we split one by hand, exactly like the table above. (The package-level keep variable forces the int onto the heap; go build -gcflags=-m confirms new(int) escapes to heap. Stack & Heap explains why.)

main.go
package main

import (
	"fmt"
	"os"
	"unsafe"
)

var keep *int // package-level, so the int below must live on the heap

func main() {
	keep = new(int)                       // an int on the heap
	addr := uintptr(unsafe.Pointer(keep)) // its virtual address, as a number

	fmt.Println("page size:  ", os.Getpagesize())
	fmt.Printf("address:     %#x\n", addr)
	fmt.Printf("page number: %#x\n", addr>>12)   // ← drop the low 12 bits
	fmt.Printf("offset:      %#x\n", addr&0xFFF) // ← keep only the low 12 bits
}
output (real run; the address varies)
page size:   4096
address:     0xc0000100f0
page number: 0xc000010
offset:      0xf0

JavaScript deliberately has no way to see memory addresses: the engine moves objects around, and hiding addresses is also a security feature. Node doesn't expose the page size either; from a shell, getconf PAGESIZE prints it.

Practitioner§03

Page tables: a four-level lookup

In one sentenceA page table isn't one giant array. It's a tree of small tables, four levels deep on x86-64, so a process only pays for the parts of its address space it actually uses.

The flat table doesn't fit

On x86-64, programs use 48-bit virtual addresses (newer CPUs can use 57; more on that below). 248 bytes is 256 TiB of address space per process. With 4 KiB pages, that's 236, about 69 billion pages.

A flat table with one 8-byte entry per page would be 236 × 8 bytes = 512 GiB, for every process. Impossible. But almost all of that space is empty: a typical program uses a few small islands (code, heap, stacks, libraries) scattered across a gigantic empty ocean.

A tree that skips the empty parts

The fix is the same as a printed street index: you don't list every house in the country on one page. You look up the region, then the city, then the street, then the house. And if a region has no houses at all, it gets no pages in the index.

The 48-bit address is cut into five pieces:

bits47–3938–3029–2120–1211–0
piecelevel-4 indexlevel-3 indexlevel-2 indexlevel-1 indexoffset
size9 bits9 bits9 bits9 bits12 bits

Nine bits can count from 0 to 511, so each table has 512 entries of 8 bytes = exactly 4 KiB, one page. Each entry in a level-4, -3 or -2 table points to a table one level down; a level-1 entry points to the frame holding the data. An empty region just has "not present" at the top, and none of the tables below it exist.

Here's what that looks like for a small Go program. Press the button to touch more heap and watch which tables appear:

The tree only exists where memory is used
level-4 table (CR3 points here)
code + globals @ 0x400000
1 × L3→1 × L2→1 × L1
Go heap @ 0xc000000000
1 × L3→1 × L2→2 × L1
stacks, libs @ 0x7f…, 0x7ffd…
1 × L3→1 × L2→3 × L1
page tables ≈ 80 kB · flat table: 512 GiB
One top-level table (all 512 entries drawn, entries 0, 1 and 255 in colour) is almost entirely not present. Only the three islands a program really uses get lower-level tables. Each level-1 table maps 2 MiB, so touching 1 GiB more heap adds 512 of them: about 2 MiB of page tables per GiB, 0.2% of the memory mapped.

Now step through a real translation, one path down that tree (the address is the Go heap address printed in §02):

A page walk, step by step
Translating 0xC0000100F0 on x86-64: four levels, one memory read each.
Code
1// What the MMU does in hardware (pseudo-code)
2func translate(va uint64) uint64 {
3 table := cr3 // this process's top-level table
4 for _, shift := range []uint{39, 30, 21} {
5 e := table[(va>>shift)&0x1FF] // 9-bit index
6 if !e.present { pageFault(va) }
7 table = e.next // go one level down
8 }
9 e := table[(va>>12)&0x1FF] // level 1
10 if !e.present { pageFault(va) }
11 return e.frame<<12 | va&0xFFF // frame + offset
12}
the virtual address, cut up
L4 000000001 = 1
L3 100000000 = 256
L2 000000000 = 0
L1 000010000 = 16
offset 000011110000 = 0x0F0
tables visited
empty
result
…
The CPU needs the byte at virtual address 0xC0000100F0 (the real Go heap address printed in §02). x86-64 uses 48 bits of it, cut into five pieces: four 9-bit indexes, one per level of the tree, and the 12-bit offset inside the page.
1 / 8

How much memory do the tables take?

Only what's used. Linux reports it as VmPTE in /proc/<pid>/status. From the real runs in §01 and §07:

processRSS (in RAM)page tables (VmPTE)
Go hello-world1.9 MB80 kB
Node 22 doing nothing42 MB480 kB

Compare that with the 512 GiB a flat table would need. The tree is why 64-bit address spaces are practical at all.

The cost: four reads before the real one

The tree has a price. To translate one address, the CPU reads four table entries, one per level, and each read depends on the one before (you can't know where level 3 is until you've read level 4). This is called a Page walk. Only after it can the CPU fetch the byte you actually asked for.

Those table entries live in ordinary memory, so they can sit in the CPU cache (a few nanoseconds) or, in the worst case, all the way out in RAM (~100 ns each). A walk costs anywhere from a few dozen to several hundred CPU cycles. If every instruction paid that, programs would crawl. The next section is about the small cache that makes almost all of them free.

Practitioner§04

The TLB: remembering recent translations

In one sentenceThe TLB is a tiny cache inside each CPU core that remembers recent "page → frame" answers. A hit costs almost nothing; a miss costs a full page walk (§03).

How small?

Exact sizes vary by CPU model, so treat these as rough, typical numbers for a recent x86-64 server core:

entriescovers (with 4 KiB pages)
first-level data TLB~6464 × 4 KiB = 256 KiB
second-level TLB~1,500–2,000~6–8 MiB

That last column is the TLB reach: how much memory you can touch without ever missing. A few megabytes. A service that hops randomly around a 2 GB cache is far outside it.

Hits depend on how you walk memory

Every value on the same page shares one TLB entry. So a loop over an array in order gets 511 hits for every miss (4096 bytes ÷ 8-byte values = 512 values per page). Random access over a big range is the opposite: nearly every access lands on a different page, and nearly every one misses.

Try it. Pick "random" over 1 GiB: with 64 entries and 4 KiB pages, almost every access misses. Switch to 2 MiB pages and it only climbs to about 12%, because 64 × 2 MiB = 128 MiB still doesn't cover 1 GiB. Now pick the realistic 1,536-entry size: 1,536 × 2 MiB = 3 GiB covers everything, and the hit rate goes to nearly 100%. Finally try "one per page" over a range just bigger than the TLB, and watch a loop evict exactly the entry it will need next.

TLB simulator
A TLB that evicts the least-recently-used entry. Pick how the program walks memory, how much it touches, and how big the TLB is.
Access pattern
jump anywhere: hash tables, pointer-chasing, tree lookups
Memory touched4 MiB
TLB entries
TLB reach = 64 entries × 4 KiB = 256 KiB. You touch 4 MiB, so translations keep getting evicted.
TLB slots (most recent first)0/64
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
·
Last accesses■ hit ■ miss → page walk
0 accesses · 0 hits · 0 misses
Long-run hit rate: 6.3%

Huge pages: more reach per entry

A Huge page is 2 MiB instead of 4 KiB, so one TLB entry covers 512× more memory (and the page walk is one level shorter). 1,500 entries × 2 MiB = 3 GiB of reach.

Here's the same random-read loop over 1 GiB, once with normal pages and once asking Linux for huge pages. (The core of the real program; the full version also builds idx and checks AnonHugePages in /proc/self/smaps_rollup to confirm the huge pages really arrived.)

hugepages.go (Linux only)
// Get 1 GiB of plain memory straight from the kernel (no file behind it).
mem, err := syscall.Mmap(-1, 0, 1<<30,
	syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_PRIVATE|syscall.MAP_ANON)
if err != nil {
	panic(err)
}
if huge {
	// ← "please use 2 MiB pages here". Fails (EINVAL) if the kernel has no THP support.
	if err := syscall.Madvise(mem, syscall.MADV_HUGEPAGE); err != nil {
		log.Println("no huge pages:", err)
	}
}
for i := range mem { // touch everything first, so we time reads, not page faults
	mem[i] = byte(i)
}

start := time.Now()
var sum byte
for _, i := range idx { // idx: 20 million random positions in mem
	sum += mem[i]
}
fmt.Println(time.Since(start), sum) // ← print sum, or the compiler may skip the reads
output (real runs on this machine)
4 KiB pages:  ~41–44 ns per read
2 MiB pages:  ~21–30 ns per read

Up to twice as fast, for one hint. Both versions miss the data caches equally (same random positions); the difference is the page walks. The numbers are approximate and vary between runs and machines.

Practitioner§05

Page faults: memory on demand

In one sentenceAsking for memory only reserves addresses. Real RAM arrives one page at a time, the first time each page is touched, through a page fault that the kernel handles invisibly.

This is Demand paging. It's the hotel from §01 preparing a room only when you first open its door.

What happens on first touch

  1. Your program calls make([]byte, 100<<20) (100 MiB). The Go runtime asks the kernel for address space. The kernel notes "this range belongs to the process" but puts nothing in the page table.
  2. Your code writes buf[0] = 1. The MMU finds no entry for that page and raises a Page fault: the CPU stops the instruction and jumps into the kernel.
  3. The kernel checks its notes: "that range is reserved, fine". It takes a free frame, fills it with zeros (so you can't read the previous owner's data), and writes the mapping into the page table.
  4. The kernel returns, and the CPU retries the same instruction. This time it works. Your code never knew.

That's a Minor page fault: fixed without touching the disk. Reading an untouched page faults too (Linux then maps a shared, read-only page of zeros, and the first write later faults again to get a real frame), so the rule is "first touch", read or write. Let's count them for real.

faults.go
package main

import (
	"fmt"
	"syscall"
)

// minorFaults asks the kernel how many minor page faults this process has had so far.
func minorFaults() int64 {
	var ru syscall.Rusage
	syscall.Getrusage(syscall.RUSAGE_SELF, &ru)
	return ru.Minflt
}

func main() {
	const size = 100 << 20 // 100 MiB
	before := minorFaults()
	buf := make([]byte, size) // ← reserve: no real RAM yet
	afterAlloc := minorFaults()

	for i := 0; i < len(buf); i += 4096 { // ← touch one byte per 4 KiB page
		buf[i] = 1
	}
	afterTouch := minorFaults()

	fmt.Println("faults from make():   ", afterAlloc-before)
	fmt.Println("faults from touching: ", afterTouch-afterAlloc)
	fmt.Println("pages in 100 MiB:     ", size/4096)
}
output (real runs, Go 1.24 and Node 22 on Linux)
# Go                              # Node
faults from make():    82         faults from allocation: 15
faults from touching:  25668      faults from touching:   25762
pages in 100 MiB:      25600      pages in 100 MiB:       25600

Allocating 100 MiB caused almost no faults. Touching it caused one fault per page, give or take some bookkeeping by the runtime. The memory was "free" until the loop wrote to it.

What a fault costs

On this machine, touching 1 GiB for the first time (262,144 faults) took about 1 second; touching it a second time took about 10–25 ms. That's roughly 4 µs per fault, including zeroing the page. (This is a virtual machine; on bare metal a minor fault is often closer to 1 µs. Either way it's thousands of times more than a normal memory write.)

So first-touch cost is real. It shows up as a slow first request after startup, or as a benchmark that's mysteriously slower on its first iteration. Benchmark warm, or pre-touch memory you know you'll need.

The three kinds of fault

kindwhat the kernel doescost (rough)
minorHands out a zeroed frame (first touch), copies a shared page (copy-on-write, below), or maps a file page that's already in RAM~1 µs
majorMust wait for the disk: a file page that isn't cached, or a page that was pushed out to Swap~50 µs (SSD) to ms (spinning disk)
invalidNothing to fix: the address isn't reserved, or the access isn't allowed → Segmentation faultthe process dies

Major faults are the dangerous ones: one is like a tiny, invisible disk read in the middle of an ordinary line of code. §06 produces 51,000 of them on purpose.

Copy-on-write: the fault that makes fork() cheap

Threads vs Processes §06 showed that fork doesn't copy the parent's memory. Here's how that works at the page level: the child gets a copy of the page table, both tables point at the same frames, and every shared page is marked read-only in both.

Reading is fine. The first write to a shared page faults; the kernel copies just that one 4 KiB page, gives the writer its private copy, and retries the write. That's Copy-on-write, and it's a minor fault too.

Page faults: first touch and copy-on-write
Click a page to write to it. Then fork() and write again from either side.
Parent · virtual pagesclick = write
no child yet: press fork()
Physical RAM in use0 frames = 0 KiB
none: nothing has been touched
minor faults (first touch): 0 · copy-on-write copies: 0 · made writable without copy: 0
Every page starts with no RAM. Write to a couple of them, press fork(), then write from the child.
Practitioner§06

mmap: files as memory

In one sentencemmap tells the kernel: "make this file appear inside my address space". After that, reading data[i] reads the file. Page faults do the loading behind the scenes.

First, where file data lives: the page cache

When any program reads a file, Linux keeps the file's pages in RAM afterwards, in the Page cache. The next read of the same data comes from RAM instead of the disk. (That's why free on a long-running server shows most RAM as buff/cache: it's file data, and the kernel drops it the moment someone needs the memory.)

There are two ways to get those bytes into your program:

Two ways to get a file's bytes
read(fd, buf, n): copy
disk
file blocks
load→
page cache
kernel RAM
copy ✂→
buf []byte
your RAM
mmap(fd): map
disk
file blocks
on fault→
page cache
kernel RAM
page table→
data []byte
same frames!
Both paths go through the kernel's page cache. read() then copies the bytes into a buffer you own. mmap instead points your page table at the page-cache frames themselves: no copy and no system call per read, but each first touch of a page is a page fault.

With read(), every call is a System call, and the kernel copies the bytes from the page cache into your buffer. With mmap, there's one syscall up front; after that, the page table points straight at the page-cache frames. No copy (zero-copy), and no syscall per read.

mmap in Go

count_lines.go
package main

import (
	"bytes"
	"fmt"
	"os"
	"syscall"
)

func main() {
	f, err := os.Open("access.log")
	if err != nil {
		panic(err)
	}
	defer f.Close()
	fi, err := f.Stat()
	if err != nil {
		panic(err)
	}

	// Map the whole file into our address space, read-only.
	data, err := syscall.Mmap(int(f.Fd()), 0, int(fi.Size()),
		syscall.PROT_READ, syscall.MAP_SHARED)
	if err != nil {
		panic(err)
	}
	defer syscall.Munmap(data) // ← unmap when done; never use data afterwards

	// data is a plain []byte: no Read() calls, no copying into a buffer.
	fmt.Println("lines:", bytes.Count(data, []byte{'\n'}))
}
output (real run, a 200,000-line file)
lines: 200000

Shared or private?

The flags argument decides what happens when you write to a mapping (opened with PROT_READ|PROT_WRITE):

  • MAP_SHARED: your writes go into the page cache, so they reach the file and are visible to every other process mapping it.
  • MAP_PRIVATE: the first write to a page triggers Copy-on-write (§05); you get a private copy and the file never changes.

Here's both at once, on the same 12-byte file:

shared_private.go (real output in the comments)
// (errors ignored for brevity; f.Close() omitted)
os.WriteFile("greeting.txt", []byte("hello world\n"), 0o644)
f, _ := os.OpenFile("greeting.txt", os.O_RDWR, 0)
rw := syscall.PROT_READ | syscall.PROT_WRITE
shared, _ := syscall.Mmap(int(f.Fd()), 0, 12, rw, syscall.MAP_SHARED)
private, _ := syscall.Mmap(int(f.Fd()), 0, 12, rw, syscall.MAP_PRIVATE)

copy(private[0:5], "HOWDY") // copy-on-write: only this mapping sees it
copy(shared[6:11], "WORLD") // goes into the page cache → into the file

onDisk, _ := os.ReadFile("greeting.txt")
fmt.Printf("%q\n%q\n%q\n", private, shared, onDisk)
// "HOWDY world\n"   ← private copy was taken before WORLD was written
// "hello WORLD\n"
// "hello WORLD\n"   ← the file changed

Mappings come in four flavours, and you use all four every day without knowing:

sharedprivate
backed by a filedatabases writing their data files; two processes sharing a filehow executables and shared libraries are loaded: code and constants come straight from the file, written pages get private copies
no file (anonymous)memory shared between a parent and its forked childrenordinary heap memory: Go's runtime and C's malloc get big chunks this way, as in §04

Major faults, measured

With mmap, a page that isn't in the page cache yet is loaded by a Major page fault. To see them, this test maps a 200 MiB file with syscall.Mmap and runs the same loop as faults.go in §05 (one byte per page, counting faults with Getrusage). Rows 3 and 4 add one line after mapping: syscall.Madvise(data, syscall.MADV_RANDOM). Before each "cold" run it empties the page cache (echo 3 > /proc/sys/vm/drop_caches, needs root).

one byte per page over a 200 MiB mmapped file (real runs, this machine)
                                  time     major faults   minor faults
cold cache                        99 ms         2            1,607
warm cache (already in RAM)        4 ms         0            1,605
cold, MADV_RANDOM (no readahead)  2.6 s    51,078              128
warm, MADV_RANDOM                 13 ms         0            3,200

Three lessons hide in that table:

  1. Major faults are expensive. Without help, each one waited about 50 µs for the disk. 51,000 of them took 2.6 seconds.
  2. Readahead hides most of them. In the first row, the kernel saw sequential access and loaded upcoming pages before they were touched. Nearly every fault found its page already in RAM, so only 2 were major. madvise(MADV_RANDOM) switches readahead off, which is right for truly random access and terrible here.
  3. There are fewer faults than pages (51,200 pages, ~1,600–3,200 faults). For file mappings, Linux maps several neighbouring cached pages on each fault (fault-around), so one fault can serve 16 or more pages.

Here's readahead in slow motion. Run it once with readahead on, then switch it off:

Readahead, live
A loop reads a mapped file one page at a time, in order. A disk read takes 4 ticks; reading a page that's already in RAM takes 1.
■ on disk■ being loaded■ in page cache■ already read□ = the loop's position
ticks 0 · major faults 0 · page already in RAM 0 · ticks spent waiting 0
With readahead, the kernel starts loading the next pages while the loop is still busy, so the loop almost never waits.

Node: no built-in mmap

Node has no mmap in its standard library. (Community native add-ons exist, but they need a C++ build step and can crash the process on the errors described below.) The usual answer is to read in chunks into one reused buffer: a syscall per chunk, and one copy, but steady memory use for files of any size:

count_lines.js
const fs = require("node:fs");

const fd = fs.openSync("access.log", "r");
const buf = Buffer.alloc(1 << 20); // one reusable 1 MiB buffer
let lines = 0, n;
while ((n = fs.readSync(fd, buf, 0, buf.length, null)) > 0) { // ← null = "continue where the last read stopped"
  for (let i = 0; i < n; i++) if (buf[i] === 10) lines++; // 10 = '\n'
}
fs.closeSync(fd);
console.log("lines:", lines); // lines: 200000

For streaming work, fs.createReadStream does the same chunking for you without blocking the event loop.

Practitioner§07

VSZ, RSS, PSS: which memory number is real?

In one sentenceVSZ = every address the process has reserved. RSS = the pages actually in RAM right now. PSS = RSS with shared pages split fairly. Watch RSS (or PSS), not VSZ.

ps shows VSZ and RSS; top calls them VIRT and RES. The kernel exposes the details in /proc/<pid>/status, where /proc/self/... always means "the process reading it".

Two hello-worlds, read from the inside

/proc/self/status (real output, trimmed)
# Go hello-world               # Node 22, empty script
VmSize:  1225224 kB  ← VSZ      VmSize:   748100 kB
VmRSS:      1920 kB  ← RSS      VmRSS:     42592 kB
RssAnon:     512 kB             RssAnon:    6388 kB
RssFile:    1408 kB             RssFile:   36204 kB
VmPTE:        80 kB             VmPTE:       480 kB

RssAnon is Anonymous memory in RAM (heap, stacks), and RssFile is file-backed pages in RAM (the program's own code, shared libraries). Node's RSS is mostly pages of the ~120 MB node executable file that it has touched. Here's how to read these numbers from code:

// Print the VmRSS line from /proc/self/status (Linux only).
b, _ := os.ReadFile("/proc/self/status")
for _, line := range strings.Split(string(b), "\n") {
	if strings.HasPrefix(line, "VmRSS:") {
		fmt.Println(line) // VmRSS:	    1920 kB
	}
}

Why is VSZ so big? Read the map

/proc/self/maps lists every reserved range, one per line: address range, permissions, and the file behind it (if any). Here's the Go hello-world, trimmed and annotated:

/proc/self/maps of a Go program (real output, trimmed)
00400000-0049f000 r-xp ... /tmp/vm/maps   ← program code: read + execute
0049f000-0056a000 r--p ... /tmp/vm/maps   ← constants: read-only
0056a000-00575000 rw-p ... /tmp/vm/maps   ← global variables: read + write
c000000000-c000400000 rw-p ...            ← Go heap: 4 MiB usable so far
c000400000-c004000000 ---p ...            ← 60 MiB more heap, reserved, no access yet
7f0fd7600000-7f0fe7780000 ---p ...        ← 257 MiB reserved for the runtime's bookkeeping
7f0fe7781000-7f1007780000 ---p ...        ← 512 MiB more of the same
7f101c0fc000-7f101c0fe000 r-xp ... [vdso] ← kernel helper code (see the System Calls topic)
7ffdffa2a000-7ffdffa4d000 rw-p ... [stack] ← main thread's stack

The permissions column is read, write, execute, then private or shared. The lines marked ---p have no permissions at all. They're pure reservations: "keep these addresses for me, I'll use them later". They cost no RAM, but they count in full towards VSZ. Go reserves room for its heap and for its bookkeeping about the heap up front; V8 does something similar for its JavaScript heap. That's why a Go or Node process routinely shows a VSZ of gigabytes while using megabytes.

When RSS lies: shared pages

RSS counts every page in RAM that this process can see, including pages it shares with others. Ten Node processes on one machine all map the same node binary; each one's RSS includes the ~36 MB of it that's in RAM, but RAM holds it only once.

PSS fixes this by dividing each shared page by the number of processes sharing it. From the same Node process, via /proc/self/smaps_rollup:

/proc/self/smaps_rollup of an idle Node process (real output, trimmed)
Rss:               42616 kB
Pss:               16282 kB   ← other node processes on this machine share the binary's pages
Shared_Clean:      35908 kB
Private_Dirty:      6452 kB   ← this process's own memory

Adding up RSS across a pool of forked workers overcounts badly; adding up PSS gives the true total. (Some tools also show USS: only the private pages, i.e. what you'd get back if this process exited.)

Now drive one process through its life and watch which number moves:

VSZ, RSS and PSS
One process (think Go or Node). Press the buttons in order and watch which number moves; then try writing again after the GC. (Sizes are illustrative.)
VSZ
1,200 MiB reserved
RSS
2 MiB in RAM
PSS
2 MiB = RSS (nothing shared)
■ live objects■ freed by GC, still held by the runtime
Starts like the real Go hello-world from §01: ~1.2 GB reserved, ~2 MB in RAM.
Staff-level§08

Why RSS doesn't drop after garbage collection

In one sentenceWhen the Garbage collector frees an object, the memory goes back to your runtime, not to the OS. RSS only drops when the runtime explicitly hands whole pages back to the kernel, and runtimes are deliberately lazy about that.

A classic incident: "We fixed the leak, memory usage dropped in our heap metrics, but the container's memory graph stayed flat at 1.8 GB." Nothing is wrong. There are three layers, and each one keeps memory for a while:

  1. Your objects. The garbage collector finds the dead ones and marks their space free (Garbage Collection covers how).
  2. The runtime's heap. That free space stays in Go's or V8's heap, ready for the next allocation. Still in RAM, still in RSS. Asking the kernel for fresh memory and faulting it in again costs page faults (§05), so keeping it is usually the fast choice.
  3. The kernel. Only when the runtime calls madvise on whole, empty pages does the kernel take the frames back. Now RSS drops. The addresses stay reserved, so VSZ doesn't change.
Three layers of “free”
Your objects, the runtime's heap, and the kernel's frames. Each layer keeps memory for its own reasons. (Numbers from real Go runs on this machine.)
1 · your objects
none alive
2 · runtime heap pages
runtime's own pages · 2 MB
3 · kernel: RAM charged
RSS 2 MB
VSZ 1.2 GB
Start: a Go program that has done nothing yet. The runtime has a couple of megabytes in use; most of its VSZ is empty reservations (§07).
1 / 5

Measured: Go

rss.go (rss() reads VmRSS from /proc/self/status, as in §07)
var sink [][]byte

func main() {
	fmt.Println("start:                ", rss())
	for i := 0; i < 50; i++ {
		b := make([]byte, 10<<20) // 50 × 10 MiB = 500 MiB
		for j := range b {
			b[j] = 1 // touch every byte, so it's really in RAM
		}
		sink = append(sink, b)
	}
	fmt.Println("after 500 MiB:        ", rss())

	sink = nil   // drop every reference…
	runtime.GC() // …and collect the garbage
	fmt.Println("after GC:             ", rss())

	time.Sleep(2 * time.Second)
	fmt.Println("2 s later:            ", rss())

	debug.FreeOSMemory() // ← force: GC + return free pages to the OS now
	fmt.Println("after FreeOSMemory(): ", rss())
}
output (real run, Go 1.24)
start:                 2036 kB
after 500 MiB:         515300 kB
after GC:              515316 kB    ← the heap is empty, RSS hasn't moved
2 s later:             512644 kB    ← the background scavenger has returned a tiny bit
after FreeOSMemory():  3204 kB      ← pages handed back to the kernel

Go returns memory on its own through a background scavenger that hands back pages it doesn't expect to need soon, gradually so it doesn't slow the program down. With GOMEMLIMIT set (Go 1.19+), it works harder as memory approaches the limit. Calling debug.FreeOSMemory() yourself is rarely needed. It makes sense after a one-off spike in a long-running process (a nightly import, a huge report), not in a request handler.

Measured: Node

rss.js (run with node --expose-gc rss.js)
const mb = (n) => `${Math.round(n / 1024 / 1024)} MiB`;
const show = (label) => {
  const m = process.memoryUsage();
  console.log(label.padEnd(16), "rss", mb(m.rss).padStart(8), "  heapUsed", mb(m.heapUsed).padStart(8));
};

show("start:");
let objs = [];
for (let i = 0; i < 3_000_000; i++) objs.push({ id: i, name: "user" + i });
show("after alloc:");
objs = null;     // drop the only reference
globalThis.gc(); // force a full GC (only exists with --expose-gc)
show("after gc():");
setTimeout(() => show("3 s later:"), 3000);
output (real run, Node 22)
start:           rss   40 MiB   heapUsed    4 MiB
after alloc:     rss  365 MiB   heapUsed  267 MiB
after gc():      rss  309 MiB   heapUsed    4 MiB   ← objects gone, most pages kept
3 s later:       rss  309 MiB   heapUsed    4 MiB

heapUsed fell by 263 MiB; RSS fell by 56 MiB. V8 keeps most of the empty heap space for future allocations and gives it back gradually (for example when the process goes idle or memory gets tight); Node has no public "give it back now" call. Memory outside the JS heap behaves differently again: a big Buffer or ArrayBuffer is allocated by the C library, which often returns large blocks to the OS as soon as they're freed.

DONTNEED vs FREE: two ways to give pages back

MADV_DONTNEEDMADV_FREE (Linux 4.5+)
what the kernel doestakes the frames away now; the next touch gets a fresh zero page (a page fault)marks them "take these if you need them"; if nobody needs the RAM, they stay
RSSdrops immediatelydrops only under memory pressure
costa page fault if you reuse the page sooncheaper; reuse is free if the kernel hasn't taken it

MADV_FREE is more efficient, but it makes RSS look high for memory the kernel could take at any moment, and that confuses dashboards and container limits. Go used MADV_FREE in 1.12–1.15 and switched back to MADV_DONTNEED by default on Linux in Go 1.16 for exactly that reason (GODEBUG=madvdontneed=0 opts back in). In /proc/<pid>/smaps_rollup, the LazyFree line shows memory in this "free but still counted" state.

Staff-level§09

Overcommit, swap and the OOM killer

In one sentenceLinux overcommits: it lets processes reserve more memory than exists, betting most of it is never touched. When the bet fails, it either pushes pages to swap or kills a process with the OOM killer.

The promise and the bet

Because RAM is only handed out on first touch (§05), the kernel can promise much more than it has. On the machine used for this article, /proc/meminfo said:

/proc/meminfo (real, trimmed) and free -m
Committed_AS:   70818944 kB   ← memory promised to all processes: ~70 GB
MemTotal:       16091 MB      ← memory that exists: 16 GB (and no swap)

That's Overcommit: 70 GB promised, 16 GB real. It works because most of those promises are reservations like the ones in §07 that are never filled. Here's what one Go program can get away with:

big := make([]byte, 8<<30) // ask for 8 GiB…
big[0] = 1                 // …but touch only the first page
// VmSize: 9623204 kB   VmRSS: 11204 kB   (real run)

8 GiB "allocated" on a 16 GB machine, 11 MB used. Ask for 64 GiB and even Linux refuses (real output: fatal error: runtime: out of memory), because the default mode rejects a single request that could never fit.

The three overcommit modes

Set in /proc/sys/vm/overcommit_memory:

modenamebehaviour
0heuristic (default)Refuse requests that are obviously too big for RAM + swap; allow everything else.
1alwaysNever refuse. Some scientific code that reserves giant sparse arrays wants this.
2strictRefuse once total promises exceed swap + overcommit_ratio% of RAM (50% by default). Allocation fails cleanly, but much less fits.

The consequence of mode 0 is the important part: "the allocation succeeded" doesn't mean the memory exists. The failure comes later, at whatever line happens to touch a page when no frame is left.

Where an out-of-memory failure really happens
your code
make 6 GiB ✓
fill it, page by page…
killed (137)
RSS
~0
2 GiB
4 GiB
limit!
kernel
page faults: hand out frames
OOM kill
The allocation succeeds. RSS grows only as pages are written. The failure arrives much later, at an ordinary-looking line of code, as a SIGKILL that the program never sees coming.

Plan A: swap

If Swap is configured, the kernel first tries to make room: it writes rarely used Anonymous memory pages to disk and frees their frames (clean file pages are simply dropped, since they can be re-read from the file). Touching a swapped-out page later is a major fault (§05).

A little swap of truly cold pages is harmless. But if the memory programs actively use doesn't fit in RAM, every access evicts something that's needed a moment later: Thrashing. The machine doesn't crash; it becomes so slow that health checks time out and it looks dead. Many production setups run with no swap at all, or very little, preferring a fast, clean kill over a slow death. vm.swappiness (0–200, default 60) tunes how eagerly the kernel swaps anonymous memory versus dropping file cache.

Plan B: the OOM killer

When nothing more can be freed, the kernel's OOM killer picks a victim and sends it SIGKILL (Threads vs Processes §11 covers the signal and exit code 137). It picks using each process's OOM score, which is mostly "how much memory does it use" on a 0–1000 scale:

$ cat /proc/self/oom_score      # how attractive a victim am I? (real: 666 here)
$ cat /proc/self/oom_score_adj  # my adjustment, −1000 … 1000 (default 0)
$ echo -1000 > /proc/<pid>/oom_score_adj   # never kill this one (needs privileges)

The kernel logs every kill, which is the first place to look after a mysterious restart (dmesg or journalctl -k). The format varies by kernel version; it looks roughly like this (illustrative):

kernel log (illustrative)
Out of memory: Killed process 4120 (node) total-vm:11834512kB, anon-rss:1984220kB, file-rss:0kB, oom_score_adj:0

total-vm is VSZ and anon-rss is the anonymous part of RSS, the numbers from §07.

In containers: the cgroup's own OOM killer

A container's memory limit is a cgroup setting (memory.max in cgroup v2). It's enforced per cgroup: your container can be OOM-killed while the host still has gigabytes free. Before the kill, the kernel tries to reclaim inside the cgroup, starting with page cache, so a container that reads lots of files is not in danger just because its usage graph includes cache. There's also memory.high, a soft limit: above it, the kernel slows the cgroup down and reclaims aggressively instead of killing.

In Kubernetes, the result of a kill is the pod status OOMKilled and exit code 137. Since SIGKILL can't be caught, your program logs nothing. That's why you tell each runtime its budget, so it can work harder before the kernel acts:

# Go 1.19+: a soft limit for the whole Go heap. The GC runs more often near it,
# and the scavenger returns memory to the OS (§08).
GOMEMLIMIT=450MiB ./server        # container limit: 512Mi
Staff-level§10

mmap vs read(): what databases choose

In one sentencemmap gives you a database's cache for free, managed by the kernel. Most big databases still build their own, because the kernel's choices about what to keep, when to write, and how to report errors aren't the ones a database needs.

Two camps

let the kernel manage it (mmap)manage it yourself (buffer pool + read/write)
examplesLMDB; bbolt (BoltDB), used by etcd; Prometheus's storage reads chunks this way; SQLite optionally (PRAGMA mmap_size)PostgreSQL, MySQL/InnoDB, most LSM engines (RocksDB, see LSM Trees)
the cachethe kernel's Page cachethe database's own buffer pool
readsdata[off]: zero-copy, no syscallpread() (read at a given file offset) into the pool: a copy and a syscall per page fetched
code sizesmall: LMDB's core is a single C filelarge: eviction, prefetching, write-back are all hand-written

Why the big databases avoid it

A 2022 paper from Carnegie Mellon, "Are You Sure You Want to Use MMAP in Your Database Management System?" (Crotty, Leis, Pavlo), lists the problems. The main ones, in this topic's terms:

  1. No say in eviction or write-back. The kernel decides which pages to drop from RAM and when to write dirty pages to disk. A database with a Write-ahead log must guarantee that a changed page is not written to disk before its log record is safe (Transactions §03 covers why); with a shared mapping, the kernel may write it whenever it likes.
  2. Errors become signals. A disk error or a truncated file shows up as SIGBUS on some random read (§06), not as an error the code can handle.
  3. Stalls you can't see or schedule. A Major page fault blocks the thread on an ordinary-looking memory read. In Go, the runtime can't park the goroutine and run another on that thread the way it does for network I/O (Async/Await §09); the OS thread is simply stuck until the disk answers.
  4. Scaling costs. Evicting a mapped page means removing it from page tables, and that means TLB shootdowns (§04) across every core. The paper measured throughput collapsing on fast NVMe drives once the data no longer fit in RAM.

Why the small ones love it

For a read-mostly store whose hot data fits in RAM, mmap is hard to beat: reads are plain memory loads, the cache survives process restarts (the page cache belongs to the kernel), and several processes can share one copy. LMDB and bbolt also keep the design simple by writing new pages copy-on-write style and never overwriting live data in place, which sidesteps problem 1.

Should you mmap?

For application code, a simple rule:

  • Big, read-only, random-access file you'll read many times (a lookup table, a model file, an index you built): mmap is a good fit, in Go via golang.org/x/exp/mmap or syscall.Mmap.
  • Streaming through a file once (logs, CSV, uploads): ordinary buffered reads. Readahead makes them fast anyway, and errors stay errors.
  • Writing data that must survive a crash: use write + fsync, not a shared mapping. Getting durability right with msync (mmap's version of fsync) is subtle.
  • In Node: streams or fs.read with a reused buffer (§06). There's no built-in mmap, and for most Node workloads it isn't the bottleneck.
Staff-level§11

Huge pages, NUMA, ASLR and Meltdown

In one sentenceThe page-table machinery has knobs that matter at scale: page size (huge pages: faster translation, coarser memory), where in RAM a page lands (NUMA), where in the address space things go (ASLR), and what is mapped while your code runs (KPTI).

Transparent huge pages: free speed, with strings attached

§04 showed random reads getting up to twice as fast with 2 MiB pages. Transparent huge pages tries to give you that without code changes: the kernel backs suitable memory with 2 MiB pages, either directly on a page fault or later, when a background thread called khugepaged merges 512 small pages into one huge one. There are three modes:

$ cat /sys/kernel/mm/transparent_hugepage/enabled
always [madvise] never          # ← this machine: only where a program asks (madvise)

The catch is that every one of these is a trade:

  • Latency spikes. A huge page needs 2 MiB of physically contiguous free RAM. On a long-running server memory gets fragmented, and the kernel may have to stop and shuffle pages around ("compaction") inside a page fault. That's milliseconds, at a random moment, on a request path.
  • Memory bloat. Touch one byte of a 2 MiB region and you may get the whole 2 MiB. Sparse heaps can use far more RSS than with 4 KiB pages.
  • Bigger copy-on-write. After a fork, one small write copies 2 MiB instead of 4 KiB (§05). Redis's own docs warn about this and recommend disabling THP.

Database vendors have gone back and forth on this. Redis recommends never; several others historically did too, while MongoDB's recent versions changed their advice towards enabling it. Read your database's current docs rather than a blog post from 2016. madvise mode is a sensible middle ground: programs that know their memory benefits ask for it, nobody else is affected.

Go changed how its runtime interacts with THP in Go 1.22 (backported to 1.21.1), and on some common kernel settings that can make the heap use noticeably more memory. GODEBUG=disablethp=1 (Go 1.21.6+) switches THP off for the Go heap, and the Go GC guide has a section on the recommended Linux THP settings.

NUMA: not all RAM is equally close

Big servers have two or more CPU sockets, and each socket has its own RAM attached. A core reading its own socket's RAM is fast; reading the other socket's goes across a link and is noticeably slower. That's NUMA, covered properly in CPU Caches.

Virtual memory decides which socket's RAM a page lands on. Linux's default policy is first touch: the frame comes from the socket of the core that first wrote to the page (§05). So a program that initialises a big array on one thread, then processes it from threads on both sockets, has made half of its accesses remote. Tools: numactl --hardware to see the layout, numastat to see where a process's memory ended up. (This machine is a VM with a single node, so it shows nothing interesting.)

ASLR: random addresses on purpose

If an attacker knows exactly where your stack, heap and libraries are, many memory-corruption exploits become easy. ASLR shuffles them on every run. Two runs of Node, reading their own /proc/self/maps:

real output, two runs
35069000-3525d000 rw-p ... [heap]    7ffcdb040000-7ffcdb063000 rw-p ... [stack]
42f1d000-43111000 rw-p ... [heap]    7ffc2a7af000-7ffc2a7d2000 rw-p ... [stack]

Same program, different addresses. (/proc/sys/kernel/randomize_va_space is 2, full randomization, on most systems.)

Go is a partial exception, and you saw it in §07: on linux/amd64, Go builds ordinary executables by default, so its code sits at a fixed 0x400000, and the Go heap starts at a fixed hint, 0xc000000000. That's acceptable because Go is memory-safe: without unsafe or cgo, there's no buffer overflow to aim with. go build -buildmode=pie produces a randomizable executable when policy requires one, and some Linux distributions build their Go packages that way.

Meltdown and KPTI: the price of a security fix

For decades, the kernel was mapped into every process's address space (marked "kernel only"), so a System call didn't need to switch page tables. In 2018, Meltdown showed that on many Intel CPUs, user code could read those kernel-only pages anyway. Modern CPUs use Speculative execution: they run instructions ahead, before they've finished checking whether an access is allowed; the forbidden read is rolled back, but it leaves traces in the CPU cache that an attacker can measure.

The fix, Meltdown & KPTI, gives each process two sets of page tables: one with the kernel mapped, used while in the kernel, and one with almost nothing of the kernel, used while your code runs. Every system call and interrupt now switches page tables. Reported overhead ranged from negligible to tens of percent for syscall-heavy workloads (databases, proxies doing small reads and writes); PCID (§04) keeps the TLB from being flushed on each switch and recovers much of it. CPUs designed after 2018 aren't vulnerable, and Linux turns KPTI off on them: this machine reports Not affected in /sys/devices/system/cpu/vulnerabilities/meltdown. System Calls covers what a syscall costs in general.

Practitioner§12

Pitfalls gallery

01

Alerting on VSZ

A Go or Node process reserves gigabytes of address space it never uses (§07). An alert on VSZ (VIRT in top) fires constantly and means nothing. Fix: alert on RSS, or on the container's memory usage against its limit.

02

“We freed it, why is RSS still high?”

The GC returned the memory to the runtime, not to the OS (§08). Fix: compare RSS with the runtime's own heap numbers (heapUsed, Go's runtime/metrics). Only worry if RSS keeps climbing across hours. Set GOMEMLIMIT / --max-old-space-size so the runtime gives memory back under pressure.

03

Trusting that allocation succeeded

With overcommit, a 4 GB make succeeds on a 2 GB container; the kill comes later, when the pages are written (§09). There's no error to check. Fix: size buffers to the data you actually have, put upper limits on anything that grows with user input, and keep the runtime's budget below the limit.

04

A container limit the runtime doesn't know about

Without GOMEMLIMIT (Go) or --max-old-space-size (Node), the runtime grows its heap as if the whole machine were available, and the cgroup kills it with exit code 137 before it ever feels pressure. Fix: set both, a bit below the limit, leaving room for stacks, buffers and native memory.

05

Benchmarking page faults by accident

The first pass over freshly allocated memory pays one page fault per 4 KiB page: on this machine, ~1 s for 1 GiB versus ~10–25 ms for the second pass (§05). A benchmark that allocates inside the timed loop measures the kernel, not your code. Fix: warm up, reuse buffers, in a Go benchmark (func BenchmarkX(b *testing.B)) call b.ResetTimer() after the setup, and in Node do a warm-up run before you start the clock.

06

Using an mmapped slice after it's gone

After syscall.Munmap(data), data is still a valid-looking []byte, but touching it crashes: unexpected fault address / [signal SIGSEGV: segmentation violation ...] (real output). The same goes for bbolt values kept after their transaction ends (§10). Fix: never let a slice of a mapping outlive the mapping; copy what you need to keep.

07

Truncating a file someone has mapped

Log rotation or a cleanup job shrinks a file while another process has it mmapped; the reader dies with SIGBUS on its next access to the missing part (§06). Fix: replace files by writing a new file and renaming it over the old one, never by truncating in place.

08

Adding up RSS across forked workers

Twenty workers forked from one parent share most of their pages. Summing their RSS can report several times more memory than the machine has. Fix: sum PSS (/proc/<pid>/smaps_rollup), or look at the container's or cgroup's total.

09

Letting a latency-sensitive service swap

A service whose working memory no longer fits in RAM doesn't crash: every request now waits on major faults, health checks time out, and the node looks dead (§09). Fix: size memory so the working set fits, prefer no swap (or very little) for latency-critical services, and alert on major fault rates (majorPageFault in Node's process.resourceUsage(), Majflt from Go's Getrusage).

Practitioner§13

Check yourself

Predict the answer before you click. Q1 is covered in §05, Q2 in §02, Q3 in §04, Q4 in §07, Q5 in §08 and Q6 in §09.

QUESTION 1 / 6
How much RAM?
buf := make([]byte, 1<<30) // 1 GiB
buf[0] = 1
buf[len(buf)-1] = 1
// read VmRSS from /proc/self/status here
QUESTION 2 / 6
Split the address

With 4 KiB pages, what are the page number and offset of virtual address 0x7F3A2C15?

QUESTION 3 / 6
Which loop misses the TLB more?

Both loops read 1 million int64s from a 2 GiB array that's already in RAM. Loop A reads indexes 0, 1, 2, … in order. Loop B reads random indexes.

QUESTION 4 / 6
The dashboard alarm

A Go service's memory graph shows VSZ 4.1 GB, RSS 180 MB. The container limit is 512 MiB. The on-call engineer wants to raise the limit to 6 GB. Good idea?

QUESTION 5 / 6
Freed but not gone
// node --expose-gc app.js
let objs = [];
for (let i = 0; i < 3_000_000; i++) objs.push({ id: i });
objs = null;
gc();
console.log(process.memoryUsage());
QUESTION 6 / 6
Exit code 137

A pod restarts with OOMKilled, exit code 137. Its code never logged an error, and every make/new call had succeeded. How?

Answered 0 / 6
Foundations§14

Cheat sheet

🏨
Addresses are virtualEvery process has its own map from virtual pages to physical frames. The CPU translates every access in hardware.
✂️
Page number + offset4 KiB pages: the last 3 hex digits are the offset and never change. The rest is looked up.
🌳
Page tables are trees4 levels on x86-64 (5 on new CPUs). Only used parts exist. A full walk = 4 dependent memory reads.
📌
The TLB makes it freeA small cache of translations. Sequential access hits; random access over GBs misses. Huge pages give 512× the reach.
👆
RAM arrives on first touchAllocating reserves addresses. Each first touch (read or write) = a minor page fault (~1–4 µs). Major faults wait for the disk.
🧬
Copy-on-writefork() shares pages read-only; the first write to one copies just that page.
🗺️
mmap = file as memoryZero-copy reads through the page cache. Errors arrive as SIGBUS. Node has no built-in mmap.
📊
VSZ vs RSS vs PSSBooked vs occupied vs fairly shared. Ignore VSZ; alert on RSS; sum PSS across processes.
♻️
Freed ≠ returnedAfter GC, RSS stays until the runtime madvises pages back. Go: scavenger, FreeOSMemory. Node: V8 decides.
💀
Overcommit → OOM killerAllocations succeed; the kill comes later as SIGKILL (exit 137). Tell the runtime its budget.
I want to…Use
See a process's memory numberscat /proc/<pid>/status (VmSize, VmRSS, VmPTE); ps -o pid,vsz,rss,comm
See its address-space mapcat /proc/<pid>/maps; per-range RSS/PSS in smaps, totals in smaps_rollup
Count page faults from codeGo: syscall.Getrusage → Minflt, Majflt; Node: process.resourceUsage()
Map a file into memoryGo: syscall.Mmap / golang.org/x/exp/mmap; Node: chunked fs.read or streams instead
Give freed memory back nowGo: debug.FreeOSMemory() (rarely needed); Node: no public API
Keep a runtime under a container limitGOMEMLIMIT=450MiB; node --max-old-space-size=384
Check overcommit, THP and swap settings/proc/sys/vm/overcommit_memory, /sys/kernel/mm/transparent_hugepage/enabled, free -m
Find out who the OOM killer tookdmesg or journalctl -k, search "Out of memory"; Kubernetes: OOMKilled, exit 137
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in OS & RuntimeCPU Caches
Read →