The largest illusion an OS maintains. Every program believes it owns
a vast, private, contiguous address space; almost none of that is true, and the
machinery that keeps the lie convincing is the most examined part of the subject.
13
Memory management & fragmentation
allocationfragmentationfirst fitbuddyslab
Why it exists
Physical RAM is one finite array of bytes and many programs want pieces of it,
at different sizes, at unpredictable times, for unpredictable durations. Whatever
strategy you pick, you eventually end up with memory that is free but
unusable — and managing that waste is the whole topic.
The two fragmentations — get these the right way round
External fragmentation
Total free memory is sufficient, but it's split into pieces too small to
satisfy any single request. You have 100 MB free and cannot allocate 60 MB
because it's in twelve scattered holes.
Memory allocated to a process that the process cannot use, because the
allocator rounds up. A 4100-byte request in 4 KB pages consumes two pages and
wastes 4092 bytes inside the second one.
Caused by: fixed-size allocation. Fixed by: smaller blocks, or size-class allocators — but
smaller blocks mean bigger page tables. It never fully goes away.
The relationship is the insight: paging trades external fragmentation for
internal fragmentation, and that's a good trade because internal waste is
bounded (at most one page per allocation, on average half a page) while external
waste is unbounded and unpredictable.
Placement strategies
Strategy
Picks
Verdict
First fit
The first hole big enough
Fastest; good enough in practice. Fragments the low end of memory over time.
Next fit
First fit, resuming from last position
Spreads wear more evenly; slightly worse fits
Best fit
The smallest hole that fits
Sounds optimal, performs worse — it manufactures tiny unusable slivers, and it scans the whole list
Worst fit
The largest hole
Aims to leave usable remainders; in practice destroys large blocks. Rarely used.
The counter-intuitive result worth stating: first fit generally beats best fit on
both speed and fragmentation. "Best" refers to the tightness of the fit, not the
outcome.
Real allocators
Allocator
How
Where
Buddy system
Split memory in halves recursively; free blocks merge with their "buddy" if it's also free
Linux physical page allocator. Fast split/merge; up to 2× internal waste from power-of-two rounding.
Slab / slub
Pre-carved caches of same-size objects, kept initialised and cache-aligned
Linux kernel objects (inodes, task_structs). Near-zero fragmentation for fixed types, and allocation is a pointer pop.
Size-class (tcmalloc, jemalloc)
Per-thread caches of size classes, backed by central arenas
Userspace malloc. Per-thread caches avoid lock contention — the reason they beat glibc under threading.
Arena / bump
Allocate by advancing a pointer; free the whole arena at once
Request-scoped work, compilers, game frames. Trivially fast; no individual free.
Why "free" memory doesn't return to the OS
A very common production puzzle. free() returns memory to the
allocator, not usually to the kernel — the allocator keeps it for the next
request, because syscalls are expensive. So RSS stays high after a spike even
though your program "freed everything". This is not a leak, it's caching; the
allocator only returns memory when a large contiguous region at the top of the heap
becomes free (brk can only shrink from the end) or when it used
mmap for a big block. Being able to explain this separates "the
process is leaking" from "the allocator is holding".
14
Paging & the TLB
page tableMMUTLBmulti-level
Why it exists
Paging solves three problems at once, which is why it won. Fixed-size blocks
eliminate external fragmentation. Indirection means a process's memory need not be
physically contiguous, so any free frame will do. And because every access goes
through a table the kernel controls, isolation and permissions come along free.
The mechanism
Split the virtual address space into pages and physical memory
into frames of the same size (4 KB typically). A page table maps
page → frame. The MMU does the translation in hardware on every single memory
access.
Page number is translated, offset is copied. The TLB is load-bearing, not a nice-to-have.
Address translation, worked
Given: 32-bit virtual addresses, 4 KB pages, single-level table
4 KB = 2¹² → offset is the low 12 bits
→ page number is the high 20 bits
→ 2²⁰ = 1,048,576 entries per process
Virtual address 0x00004A3F
= 0000 0000 0000 0000 0100 1010 0011 1111
└──────── page 0x00004 ───────┘└─ offset 0xA3F ─┘
page number = 0x4 = 4
offset = 0xA3F = 2623
Page table[4] = frame 0x11 (17), present, rw
Physical address = (17 × 4096) + 2623 = 69,632 + 2,623 = 72,255
= 0x00011A3F // note the offset is unchangedQuick facts to derive rather than memorise:
page size 2ⁿ → offset = n bits
virtual address v bits → page number = v − n bits
entries per table = 2^(v−n)
a 4-byte entry, 20-bit page number → 4 MB of page table PER PROCESS
100 processes → 400 MB of page tables for mostly-empty address spaces.
That waste is exactly what multi-level tables fix.
Multi-level page tables
Rather than one giant flat array, use a tree. Only the branches you actually use
get allocated — and a typical process uses a tiny, sparse fraction of its address
space, so nearly all branches are absent.
x86-64, 4-level, 4 KB pages: 48 bits of virtual address in use
9 bits → PML4 index (512 entries)
9 bits → PDPT index
9 bits → PD index
9 bits → PT index
12 bits → offset
= 48 bits → 256 TB of address space
A process touching 8 MB needs a handful of tables, not 512 GB of them.
Cost: 4 memory accesses per translation on a TLB miss — hence the TLB,
and hence huge pages.
Huge pages (2 MB or 1 GB): fewer entries cover far more memory.
TLB REACH = entries × page size
1536 entries × 4 KB = 6 MB ← a 1 GB working set thrashes the TLB
1536 entries × 2 MB = 3 GB ← now it fits
Used by databases and JVMs deliberately. Cost: more internal
fragmentation, and allocation can fail once memory is fragmented.
Page table entry contents
The flags are asked about because each one enables a feature:
Bit
Meaning
Enables
Present / valid
Is this page in physical memory?
Demand paging — a clear bit triggers a page fault
Dirty
Has it been written since being loaded?
Clean pages can be evicted without a disk write — a large saving
Accessed / referenced
Touched recently?
Clock/LRU approximation for replacement
R/W, User/Supervisor, NX
Permissions
Read-only code, copy-on-write, non-executable stack (defeats a whole class of exploits)
Frame number
Where it actually is
The translation itself
Say this
"Paging splits the virtual space into fixed-size pages and physical memory into
frames, and a per-process page table maps between them. Fixed size kills external
fragmentation and the indirection means a process doesn't need contiguous physical
memory. A flat table for a 32-bit space would be 4 MB per process, so real systems
use multi-level tables and only allocate the branches in use — x86-64 uses four
levels. That costs four memory accesses per translation, which is why the TLB
exists: it caches translations and hits over 99% of the time, and without it paging
would make every memory access several times slower."
15
Segmentation
segmentslogical divisionvs paging
Why it existed
Segmentation divides memory the way the programmer thinks: a code
segment, a stack segment, a heap segment, one per array. Each has a base and a
limit, so bounds checking and per-segment permissions are natural, and growing the
stack means growing one segment. Paging divides memory the way the
hardware finds convenient — fixed blocks with no meaning at all.
Paging
Segmentation
Block size
Fixed (4 KB)
Variable — as big as the logical unit needs
Divided by
Hardware convenience
Logical meaning
Address
One number, split by the MMU
Explicitly two parts: <segment, offset>
Fragmentation
Internal (bounded, ≤1 page)
External (unbounded)
Programmer visible
No
Yes
Sharing & protection
Per page — awkward unit for "this function"
Per segment — natural: share one code segment between processes
Growth
Add a page anywhere
Needs contiguous room after the segment, or a costly move
Why paging won
External fragmentation. Variable-size segments leave holes that eventually
cannot be filled, and the only cures are compaction (copying live memory around,
which is slow) or refusing allocations while memory is technically free. Paging's
fixed blocks make that impossible by construction: any free frame satisfies any
page. The price is internal fragmentation, which is bounded and small — a trade
the industry made permanently.
The historical answer is segmentation with paging: divide
logically into segments, then page each segment so it needn't be contiguous. x86
supported exactly this. In x86-64 long mode, segmentation is largely vestigial —
the base is forced to zero for the main segments — and Linux uses a flat paged
model. Segments survive as FS/GS, which the OS uses for
thread-local storage and per-CPU data.
Say this
"Segmentation divides memory into variable-size logical units with a base and
limit each, which makes protection and sharing natural — you can mark one code
segment read-only and share it between processes. It lost to paging because
variable sizes cause external fragmentation, and the only fixes are compaction or
refusing allocations. Paging's fixed frames mean any free frame fits any page, so
external fragmentation cannot occur; you pay bounded internal fragmentation
instead. Modern x86-64 keeps segments only for thread-local storage."
16
Virtual memory & page replacement
demand pagingpage faultLRUClockBelady
Why it exists
Programs are bigger than RAM, and they don't need all of themselves at once —
the 90% of your binary handling error paths and rare options is never touched.
Virtual memory loads pages on demand, so a process's address space can far
exceed physical memory, more processes fit at once, and startup is fast because you
don't load what you don't run.
Handling a page fault
The MMU finds the present bit clear and raises a page fault — a trap into the
kernel. The faulting instruction is suspended, not aborted.
The kernel checks whether the address is legal for this process. If not:
SIGSEGV, process dies. This is what a segfault is.
It finds a free frame. If none is free, it evicts a victim —
writing it to disk first if it's dirty, which is why the dirty bit matters.
It reads the page in from disk (or the swap file, or zeroes it for a fresh
anonymous page).
It updates the page table and the TLB, then re-executes the faulting
instruction, which now succeeds.
Minor fault — the page is already in RAM (in the page cache, or shared with
another process, or just needs a copy-on-write copy). ~microseconds. Very common
and harmless.
Major fault — must read from disk. 1–10 ms on SSD-to-spinning-disk, i.e.
~10,000× a memory access. This is the number that makes virtual memory
dangerous.
Effective access time = (1 − p) × memory + p × fault_time
With 100 ns memory and 8 ms faults, a fault rate p = 0.001 gives
0.999 × 100 ns + 0.001 × 8,000,000 ns ≈ 8.1 µs — 81× slower.
For under 10% slowdown you need p < ~0.0000012. Page faults must be
vanishingly rare, not merely uncommon.
Replacement algorithms
Algorithm
Evicts
Verdict
Optimal (OPT/MIN)
The page used furthest in the future
Provably best, unimplementable — needs the future. Used as the benchmark others are measured against.
FIFO
The oldest loaded page
Trivial; ignores usage, so it happily evicts a hot page. Suffers Belady's anomaly.
LRU
Least recently used
Good approximation of OPT under locality. Expensive to do exactly — needs a timestamp or list update on every access.
Clock / Second chance
The first page whose reference bit is 0, sweeping a circular list
The practical answer. Approximates LRU using one hardware bit and no per-access bookkeeping.
LFU
Least frequently used
An old-but-once-popular page never leaves. Needs ageing/decay.
Random
Any page
Surprisingly not terrible, and immune to worst-case patterns
Traced side by side
Reference string: 7 0 1 2 0 3 0 4 2 3 0 3 2 · 3 frames
FIFO
7 | 7 0 | 7 0 1 | 7 0 1 2 | 2 0 1 ← evict 7 (oldest)
0 | hit 3 | 2 3 1 ← evict 0 0 | 2 3 0 ← evict 1
4 | 4 3 0 2 | 4 2 0 3 | 4 2 3 0 | 0 2 3
3 | 0 3 ... → 15 faultsLRU
same start, but at the 4: frames hold 2 0 3, least recently USED is 2
→ evict 2, not 0 → 12 faultsOPT
at each eviction, look forward and drop the page needed latest
→ 9 faults ← the floor. LRU's 12 is respectable; FIFO's 15 is not.
Belady's anomaly
More frames can cause more page faults under FIFO. The classic
string 1 2 3 4 1 2 5 1 2 3 4 5 gives 9 faults with 3 frames and
10 with 4.
Why: FIFO's eviction order has no relationship to future use, so adding a frame
can change which pages happen to be resident in an unluckier way. Algorithms in the
stack class — where the set of pages held with N frames is always a
subset of the set held with N+1 — cannot suffer it. LRU and OPT are stack
algorithms; FIFO and Clock are not. This is a favourite exam question precisely
because it's counter-intuitive.
Frame allocation and the second policy
Replacement is only half the decision; how many frames each process gets is the
other half.
Local replacement: a faulting process evicts only its own
pages. Predictable per-process performance, but a process can thrash inside its own
allocation while frames sit idle elsewhere.
Global replacement: evict from anyone. Better overall
utilisation, but one greedy process degrades everyone and performance becomes
non-reproducible. Most real systems are global with limits.
Proportional allocation gives frames by process size rather
than equally, which stops a tiny process from hoarding.
17
Thrashing & memory pressure
working setswapOOM killercopy-on-write
Why it exists as a topic
Thrashing is the one failure mode where the OS's coping mechanism becomes the
problem. The system spends more time moving pages than executing instructions, CPU
utilisation collapses, and — the vicious part — a naive scheduler sees idle CPU and
admits more processes, making it worse.
The working set model
W(t, Δ) = the set of pages referenced in the last Δ references — the
process's current locality.
If a process has enough frames to hold its working set, it faults only when
locality shifts. If it has fewer, it faults constantly, because it
repeatedly evicts a page it is about to need.
The stability condition: Σ (working set sizes) ≤ available frames.
Violate it and the system thrashes. This is the entire theory in one line.
The practical control is page fault frequency: measure each
process's fault rate, give more frames to processes above the upper bound, take
frames from processes below the lower bound, and if no process can be satisfied,
suspend one entirely to let the rest run. Counter-intuitive but correct —
running fewer processes well beats running all of them badly.
Swap
Term
Means
Paging
Moving individual pages between RAM and disk. Normal, continuous, healthy.
Swapping
Strictly, moving an entire process out. Colloquially used for paging to the swap area.
Thrashing
Page fault rate so high that useful work approaches zero.
swappiness
Linux dial (0–100, default 60) trading anonymous-page eviction against page-cache eviction. Databases often set it low to keep their own memory resident.
Some swap is healthy: it lets the kernel evict genuinely cold anonymous pages and
use the RAM for page cache instead. Zero swap doesn't prevent memory pressure; it
just removes an option and makes the OOM killer fire sooner.
The OOM killer, and why it kills the wrong thing
When Linux cannot reclaim enough memory, it picks a victim by
oom_score — dominated by memory footprint — and SIGKILLs it. The
largest process is usually your main service. Symptoms: the process vanishes with
no application log and no stack trace; the evidence is only in
dmesg/journal as Out of memory: Killed process…. Being
able to say "check dmesg, it was probably OOM-killed" is a
recognisable production instinct.
Worse: Linux overcommits by default —
malloc succeeds against memory that doesn't exist, because most
programs don't touch everything they allocate. So the failure arrives not at
allocation time as a clean NULL, but later, at an arbitrary page
touch, as a kill. Tune with oom_score_adj, cgroup memory limits, or
vm.overcommit_memory.
Copy-on-write
fork() must give the child a copy of a possibly-gigabyte address space.
Copying it eagerly would be absurd — and usually wasted, because the
child typically calls exec() immediately and discards all of it.
Instead: map the SAME frames into both, marked read-only.
· Reads are free and shared.
· The first WRITE traps → kernel copies just that one page, marks
both writable, resumes.
→ cost is proportional to pages actually MODIFIED, not pages owned.
Same trick appears everywhere once you recognise it: mmap of a file,
shared libraries (one physical copy of libc for the whole system),
container image layers, database snapshots, and Redis's BGSAVE — which
forks and lets COW give it a consistent point-in-time view.
The gotcha: a write-heavy child after fork can double memory usage as
COW faults copy page after page. Redis is famous for this — a background
save during heavy writes can spike RSS toward 2×.
Diagnosing memory in production
$ free -h
total used free shared buff/cache available
Mem: 31G 28G 0.4G 0.1G 2.6G 1.1G
Swap: 2.0G 1.9G 0.1G
// swap almost full + available tiny = pressure. Not proof of thrash.$ vmstat 1
r b swpd free si so cs
2 9 1.9G 400M 4200 380042000// si/so = pages swapped IN/OUT per second. Sustained non-zero
// si+so is THE signature of thrashing. b=9 blocked, cs very high.$ ps -eo pid,comm,rss,maj_flt --sort=-rss | head -3
PID COMMAND RSS MAJFL
4211 java 24500000 182004// major faults = disk hits$ dmesg -T | grep -i "killed process"
// the answer to "why did my service disappear at 3am"
RSS vs VSZ — the reading everyone gets wrong
VSZ is virtual size: everything mapped, including memory never
touched, files mapped, and shared libraries. It can be enormous and means almost
nothing — a JVM with a 40 GB VSZ is not using 40 GB. RSS is
resident set size: physical pages actually present, and it's the number that
matters for pressure. But RSS double-counts shared pages across
processes, so summing RSS over a process tree overstates real usage. For an honest
per-process figure use PSS (proportional set size, in
/proc/PID/smaps_rollup), which divides shared pages by the number of
sharers.
Say this
"Thrashing is when the sum of the processes' working sets exceeds physical
memory, so every process keeps evicting a page it's about to need. The
characteristic signature is CPU utilisation dropping while disk I/O saturates —
and the trap is that a naive scheduler sees idle CPU and admits more work, which
deepens it. The fix is to reduce the degree of multiprogramming: suspend a process
entirely so the rest have enough frames. On Linux I'd confirm it with sustained
non-zero si/so in vmstat plus rising major
faults — and if the process vanished instead, check dmesg for the OOM
killer."