Linux Memory Management Deep Dive
Linux Memory Management Deep Dive
Memory management is one of those kernel subsystems that most engineers interact with only when something goes wrong — an OOM kill, a mysterious slowdown, a latency spike that correlates with nothing obvious. Understanding how Linux manages memory doesn’t just help you debug these situations; it changes how you architect systems in the first place.
This post covers the full stack: virtual memory fundamentals, the page allocator, huge pages (transparent and explicit), NUMA topology, the OOM killer, and the tools for measuring and tuning memory behavior.
Virtual Memory Fundamentals
Every process on Linux operates in a private virtual address space. The kernel maintains a mapping from virtual addresses to physical frames — the page table. This separation gives the OS several important properties:
- Isolation: processes can’t access each other’s memory (unless explicitly shared)
- Overcommit: the virtual address space can exceed physical RAM
- Lazy allocation: memory is only backed by physical pages on first access
The Page Table Walk
On x86-64, the page table is a 4-level (or 5-level on recent CPUs) radix tree:
Virtual Address (48-bit):
[PML4 index][PDP index][PD index][PT index][Page offset]
[ 9 bits ][ 9 bits ][ 9 bits ][ 9 bits ][ 12 bits ]
Each level is a table of 512 64-bit entries, each pointing to the next level or (at the leaf) to a physical page frame. A full walk requires 4 memory accesses for the table entries plus 1 for the actual data — 5 total. The Translation Lookaside Buffer (TLB) caches recent translations to amortize this cost.
TLB misses are expensive. On modern CPUs, a TLB miss can take 100–300 cycles to service if the page tables are cold in cache. This is why huge pages matter.
The Virtual Memory Areas (VMAs)
The kernel tracks a process’s virtual address space as a sorted list (and rbtree) of vm_area_struct objects, each describing a contiguous region with uniform permissions and backing:
|
|
Output:
55a1b4a00000-55a1b4b2e000 r-xp 00000000 08:01 1234567 /usr/sbin/nginx # text
55a1b4d2e000-55a1b4d3b000 r--p 0012e000 08:01 1234567 /usr/sbin/nginx # rodata
55a1b4d3b000-55a1b4d3e000 rw-p 0013b000 08:01 1234567 /usr/sbin/nginx # data
55a1b5000000-55a1b5200000 rw-p 00000000 00:00 0 [heap]
7f8a00000000-7f8a40000000 rw-p 00000000 00:00 0 [anonymous]
7ffd12345000-7ffd12366000 rw-p 00000000 00:00 0 [stack]
/proc/PID/smaps gives per-VMA statistics including RSS (resident), PSS (proportional share), and swap usage.
Page Allocation
The Buddy Allocator
The kernel’s physical memory allocator is the buddy system. Physical memory is organized into zones (DMA, Normal, highmem on 32-bit), and within each zone, free pages are tracked in 11 free lists, one for each order from 2^0 (4 KB) to 2^10 (4 MB).
To allocate a page of order N:
- Look in the order-N free list
- If empty, split a block from order N+1, put one half back, return the other
- On free, check if the “buddy” is also free; if so, merge back up
This gives O(log n) allocation and keeps fragmentation bounded. But over time, memory fragments — it becomes hard to find contiguous ranges for large allocations.
|
|
The Slab Allocator
The buddy allocator works at page granularity (4 KB). For kernel objects that are much smaller (inodes, dentries, socket buffers), the slab allocator manages per-object caches on top of pages from the buddy allocator.
|
|
The slab allocator (SLUB in modern kernels) dramatically reduces internal fragmentation for small kernel objects and improves cache locality by keeping same-type objects together.
/proc/meminfo Explained
/proc/meminfo is the primary window into kernel memory state. The fields that actually matter:
|
|
MemTotal: 131072000 kB # Total usable RAM
MemFree: 512000 kB # Completely idle pages
MemAvailable: 98304000 kB # Estimated available for new processes (*)
Buffers: 2048000 kB # Page cache for block device metadata
Cached: 24576000 kB # Page cache for file data
SwapCached: 0 kB # Pages in swap that are also in RAM (read back but not evicted yet)
Active: 45000000 kB # Recently used, unlikely to be reclaimed
Inactive: 28000000 kB # Not recently used, candidate for reclaim
Active(anon): 20000000 kB # Anonymous active (heap, stack, mmapped files)
Inactive(anon): 8000000 kB # Anonymous inactive
Active(file): 25000000 kB # File-backed active (page cache)
Inactive(file): 20000000 kB # File-backed inactive
Unevictable: 1024000 kB # Locked pages (mlock), cannot be reclaimed
Mlocked: 512000 kB # Pages locked with mlock()
SwapTotal: 16777216 kB # Total swap
SwapFree: 16777216 kB # Free swap
Dirty: 131072 kB # Pages modified but not yet written to disk
Writeback: 0 kB # Pages currently being written back
AnonPages: 28000000 kB # Anonymous pages in userspace
Mapped: 4096000 kB # Pages mapped into process address spaces
Shmem: 2048000 kB # Shared memory (tmpfs, SHM)
KReclaimable: 3145728 kB # Kernel memory that can be reclaimed under pressure
Slab: 4194304 kB # Total slab allocations
SReclaimable: 3145728 kB # Reclaimable slab (caches)
SUnreclaim: 1048576 kB # Unreclaimable slab (kernel structures)
KernelStack: 131072 kB # Kernel stacks
PageTables: 524288 kB # Page table memory
CommitLimit: 131072000 kB # Total committed memory allowed (with overcommit)
Committed_AS: 90000000 kB # Currently committed virtual memory
HugePages_Total: 1024 # Huge pages configured
HugePages_Free: 512 # Huge pages available
HugePages_Rsvd: 64 # Reserved but not yet allocated
Hugepagesize: 2048 kB # Size of each huge page
AnonHugePages: 10240000 kB # Anonymous memory covered by THP
MemAvailable (not MemFree) is the right metric for “how much memory can a new process use?” It accounts for reclaimable page cache and slab caches, not just idle pages. MemFree of 512 MB on a 128 GB machine is normal and not a problem.
Dirty should be monitored. High dirty page counts cause write stalls when the kernel forces writeback. Related tunables:
|
|
For write-heavy workloads (databases, logging), lowering dirty_background_ratio to 5% and dirty_ratio to 10% reduces I/O burst size and smooths write latency.
Memory Overcommit
By default, Linux allows processes to mmap() or malloc() more memory than physically exists. This works because:
- Virtual pages are only backed by physical frames on first write (copy-on-write)
fork()creates a copy of the parent’s address space without copying pages
The vm.overcommit_memory sysctl controls the policy:
| Value | Behavior |
|---|---|
| 0 (default) | Heuristic — allow overcommit up to a limit |
| 1 | Always allow any overcommit (dangerous) |
| 2 | Never overcommit; enforce CommitLimit |
For databases and other memory-sensitive applications, overcommit mode 2 is safer — it prevents the OOM killer from terminating processes by refusing allocations upfront:
|
|
Huge Pages
The standard Linux page is 4 KB. The TLB covers a limited virtual address range — on a typical x86 CPU, the L1 TLB covers 32–64 entries × 4 KB = 128–256 KB. For a process with a 10 GB working set, TLB coverage is tiny and misses are frequent.
Huge pages solve this by using larger page sizes: 2 MB (hugepages) or 1 GB (gigantic pages) on x86-64. A 2 MB page covers 512× more address space per TLB entry.
Explicit Huge Pages
The traditional approach: pre-allocate a pool of 2 MB pages at boot and have applications request them explicitly via mmap(MAP_HUGETLB) or shmget(SHM_HUGETLB).
|
|
In application code (C):
|
|
Java, PostgreSQL, Oracle, and many other applications have explicit huge page support. For PostgreSQL:
|
|
|
|
Transparent Huge Pages (THP)
THP is the kernel’s automatic huge page promotion. It runs in the background, scans for naturally-aligned 2 MB regions of anonymous memory, and promotes them to huge pages without application changes.
|
|
THP sounds like a pure win, but has real downsides:
The case against THP (always mode):
- Latency spikes: khugepaged (the THP daemon) does memory compaction that causes periodic stalls. Applications with strict p99 latency requirements (Redis, databases) often disable THP.
- Memory waste: a huge page is promoted for the whole 2 MB region even if only part of it is touched. Copy-on-write copies a whole 2 MB on fork instead of 4 KB.
- False sharing: two threads using different parts of a huge page share a TLB entry, potentially causing more TLB shootdowns.
Recommended configuration for latency-sensitive services:
|
|
Recommended for throughput-oriented HPC / batch workloads:
|
|
In your application, opt into THP selectively with madvise:
|
|
1 GB Gigantic Pages
For very large working sets (ML inference engines, in-memory databases), 1 GB pages further reduce TLB pressure. Must be allocated at boot:
|
|
NUMA: Non-Uniform Memory Access
Modern multi-socket servers have NUMA architecture: each CPU socket has local memory with low latency (typically 80–100 ns), and remote memory attached to another socket with higher latency (typically 150–300 ns).
|
|
The distance matrix shows relative access costs. Local access = 10, remote = 21 (2.1× slower).
NUMA Allocation Policies
The kernel’s vm.zone_reclaim_mode and per-process NUMA policy control where memory is allocated:
|
|
zone_reclaim_mode controls how aggressively the kernel reclaims local memory before going to a remote NUMA node:
|
|
For most workloads, zone_reclaim_mode=0 is correct. For workloads with a working set that fits in one node’s memory, zone_reclaim_mode=1 reduces remote accesses.
NUMA and the Linux Scheduler
The scheduler tracks per-task NUMA placement using NUMA balancing. It periodically unmaps pages and re-faults them on the node where the accessing CPU lives, gradually migrating memory to where it’s used.
|
|
For latency-sensitive applications, it can be better to pin processes to specific NUMA nodes with numactl and disable automatic NUMA balancing:
|
|
The OOM Killer
When the system runs out of memory and can’t reclaim any, the Out-Of-Memory killer selects and terminates a process to free memory. Understanding how it selects victims — and how to tune it — is essential for production systems.
OOM Score
Every process has an oom_score (0–1000) that represents how “killable” it is. Higher score = more likely to be killed. The score is roughly proportional to the process’s RSS relative to total memory.
|
|
Tuning OOM Behavior
oom_score_adj: Adjust how likely a process is to be killed. Range: -1000 (never kill) to +1000 (kill first).
|
|
In systemd units:
|
|
vm.oom_kill_allocating_task: By default, the OOM killer selects the “worst” process. Set to 1 to kill the process that triggered the OOM instead (simpler, avoids killing important processes):
|
|
vm.panic_on_oom: For high-availability systems where an OOM kill is unacceptable (database, control plane):
|
|
Cgroup Memory Limits
A better approach than relying on the OOM killer is to constrain processes with cgroup memory limits. When a cgroup hits its limit, only processes in that cgroup are OOM-killed:
|
|
In systemd:
|
|
In Kubernetes:
|
|
Reading OOM Kill Events
|
|
Set up monitoring for OOM kills. The metric node_vmstat_oom_kill in the Prometheus node_exporter tracks this.
Memory Pressure and Swappiness
vm.swappiness
vm.swappiness (0–200, default 60) controls the kernel’s tendency to swap anonymous pages versus evicting file-backed page cache pages.
swappiness = 0: avoid swapping anonymous pages until absolutely necessary (prefer to drop cache)swappiness = 60: balanced defaultswappiness = 100: treat anonymous pages and file cache equallyswappiness = 200: aggressively swap anonymous pages even when cache could be evicted
For databases and caches that store their working set in anonymous memory, low swappiness is usually correct:
|
|
Memory Compaction
Over time, physical memory fragments — it’s hard to find contiguous ranges for huge page allocations. The kernel runs kcompactd to defragment:
|
|
/proc/vmstat: Detailed Memory Statistics
|
|
High pgmajfault rates indicate your working set doesn’t fit in RAM. High pgscan with low pgsteal indicates memory pressure where the kernel is working hard to find pages to reclaim.
perf mem: Memory Access Profiling
perf mem uses hardware performance counters (specifically PEBS — Precise Event-Based Sampling) to sample memory accesses and identify cache misses, NUMA misses, and TLB misses.
|
|
Example output:
# Overhead Samples Local Memory access
24.50% 245 L3 miss [k] __memcpy_avx_unaligned
18.32% 183 L1 miss [.] process_batch
12.10% 121 LFB hit [.] hash_lookup
8.90% 89 L2 miss [.] tree_walk
5.60% 56 Remote [.] global_counter_update
The “Remote” entries are NUMA remote accesses — these are expensive and often fixable with numactl.
perf stat for Memory Counters
|
|
Target ratios:
- LLC miss rate < 1%: good
- LLC miss rate 1–10%: investigate
- LLC miss rate > 10%: working set exceeds L3 cache, probably memory-bound
vmtouch: Working Set Analysis
vmtouch shows which files are currently in the page cache and allows you to warm the cache:
|
|
Practical Tuning Scenarios
High-Throughput Web Server
|
|
PostgreSQL Database
|
|
Redis
|
|
ML Training (GPU + Large Batch)
|
|
Quick Reference: Key Files and Commands
| What | Where |
|---|---|
| Memory overview | /proc/meminfo |
| Per-process memory | /proc/PID/status, /proc/PID/smaps |
| Process VMAs | /proc/PID/maps |
| NUMA stats | numastat, numactl --hardware |
| Buddy allocator | /proc/buddyinfo |
| Slab caches | /proc/slabinfo, slabtop |
| VM counters | /proc/vmstat |
| Huge page config | /proc/sys/vm/nr_hugepages |
| THP config | /sys/kernel/mm/transparent_hugepage/ |
| OOM scores | /proc/PID/oom_score, /proc/PID/oom_score_adj |
| Memory profiling | perf mem record/report |
| Page cache usage | vmtouch |
| Cgroup limits | /sys/fs/cgroup/PID/memory.{max,current} |
Memory management tuning is empirical — measure first, then tune. The tools above give you the observability to understand what the system is actually doing, which is the prerequisite for making it better.
Comments