Overview
Memory is one of the most precious resources in a Linux system. Understanding how the kernel manages memory not only helps you troubleshoot OOM and memory leak issues in production, but also enables better decisions in capacity planning and performance tuning. This article starts from the virtual memory model and covers core topics including Page Cache, Swap policies, OOM Killer principles, cgroup v2 memory limits, slab/shmem tuning, with multiple production case studies.
Virtual Memory Model
Address Space Layering
Linux uses a virtual memory mechanism where each process has its own independent virtual address space:
| Layer | Range | Visible to Userspace |
|---|---|---|
| User space | 0x000000000000 ~ 0x00007FFFFFFFFFFF | Yes |
| Non-canonical area | 0x0000800000000000 ~ 0xFFFF7FFFFFFFFFFF | No (hole) |
| Kernel space | 0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF | No |
On 64-bit systems, user space theoretically has 128TB (47-bit addressing), and kernel space also has 128TB. The actual usable space is limited by TASK_SIZE and mm_struct.
Memory Zone Division
The kernel divides physical memory into multiple zones, each with different purposes:
$ cat /proc/zoneinfo | grep -E "^Node|pages free|high|normal|DMA"
| Zone | Purpose | Typical Scenario |
|---|---|---|
| DMA | Below 16MB, legacy ISA device DMA | Rarely used |
| DMA32 | Below 4GB, 32-bit DMA devices | Old hardware |
| Normal | Above 4GB, most memory allocations | Primary use |
| Movable | Migratable pages, supports memory hot-plug | Virtualization/HugePages |
When the Normal zone is exhausted, the kernel borrows pages from the DMA32 zone (watermark mechanism), but frequent borrowing degrades performance.
Page Size and HugePages
The default page size is 4KB. HugePages can reduce TLB misses:
# View current HugePages configuration
$ cat /proc/meminfo | grep -i huge
AnonHugePages: 81920 kB
HugePages_Total: 0
HugePages_Free: 0
HugePages_Rsvd: 0
HugePages_Surp: 0
Hugepagesize: 2048 kB
# Configure 100 x 2MB huge pages
$ echo 100 > /proc/sys/vm/nr_hugepages
# Transparent HugePages (THP) status
$ cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never
THP policy recommendations:
| Scenario | THP Policy | Reason |
|---|---|---|
| Database (MySQL/PostgreSQL) | never | Random access pattern, huge pages waste memory |
| Virtualization (KVM) | always | Contiguous memory access, reduces TLB misses |
| Container runtime | madvise | Balances performance and memory waste |
| General web server | madvise | Default recommendation |
Page Cache Mechanism
How It Works
Page Cache is a memory region used by the kernel to cache file data. When a process calls read(), the kernel first checks the Page Cache — on a hit, data is returned directly (avoiding disk I/O); on a miss, a disk read is triggered and the data is cached. write() by default writes to the Page Cache first, marking the page as dirty, and the pdflush/writeback thread flushes it to disk asynchronously.
$ cat /proc/meminfo | grep -E "Cached|Buffers|SwapCached|Dirty|Writeback"
Buffers: 12345 kB
Cached: 1234567 kB
SwapCached: 5678 kB
Dirty: 123 kB
Writeback: 0 kB
Dirty Page Flush Parameters
# When dirty pages reach this percentage of memory, background threads start flushing
$ sysctl vm.dirty_background_ratio
vm.dirty_background_ratio = 10
# When dirty pages reach this percentage of memory, writes are blocked and forced flushing occurs
$ sysctl vm.dirty_ratio
vm.dirty_ratio = 20
# Maximum age of dirty data (1/100 second)
$ sysctl vm.dirty_expire_centisecs
vm.dirty_expire_centisecs = 3000
# Interval for waking up flush threads (1/100 second)
$ sysctl vm.dirty_writeback_centisecs
vm.dirty_writeback_centisecs = 500
Recommended configurations for different workloads:
| Scenario | dirty_background_ratio | dirty_ratio | Notes |
|---|---|---|---|
| General server | 10 | 20 | Default, balanced |
| Database server | 5 | 10 | Reduces burst I/O peaks |
| Large memory machine (>128GB) | 1 | 5 | Use bytes instead of ratio |
| Write-intensive | 15 | 30 | Allows more caching |
For large memory machines, use
_bytesinstead of_ratio:vm.dirty_background_bytes = 268435456 # 256MB vm.dirty_bytes = 1073741824 # 1GB
Manually Reclaiming Page Cache
# Free page cache
$ echo 1 > /proc/sys/vm/drop_caches
# Free dentries and inodes
$ echo 2 > /proc/sys/vm/drop_caches
# Free all (pagecache + dentries + inodes)
$ echo 3 > /proc/sys/vm/drop_caches
Warning:
drop_cachescauses a brief I/O spike; use with caution in production. Runsyncbefore calling it.
Swap Strategy
How Swap Works
Swap allows the kernel to write inactive anonymous pages (anon pages) to the swap partition, freeing physical memory for processes that need it more. Swap usage does not necessarily mean memory is insufficient — the kernel proactively swaps cold data out to improve overall efficiency.
swappiness Parameter
# swappiness range 0-100 (default 60)
# 0 = avoid swap as much as possible (kernel 3.5+ does not fully disable)
# 1 = almost never use swap
# 60 = default, balanced
# 100 = aggressively use swap
$ sysctl vm.swappiness
vm.swappiness = 60
| Scenario | swappiness | Reason |
|---|---|---|
| Database server | 1 | Swap causes latency spikes |
| Container host | 10 | Prevents containers being slowed by swap |
| Desktop system | 60 | Default |
| Embedded device | 100 | Extremely memory-constrained |
vfs_cache_pressure
# Controls the reclaim tendency of inode/dentry cache relative to pagecache
# 0 = never reclaim (not recommended)
# 100 = default
# >100 = more aggressive reclaim
$ sysctl vm.vfs_cache_pressure
vm.vfs_cache_pressure = 100
Swap Status Diagnosis
# View swap usage
$ swapon --show
NAME TYPE SIZE USED PRIO
/dev/dm-1 partition 8G 1.2G -2
# View swap usage per process
$ for f in /proc/*/status; do awk '/VmSwap|Name/{printf $2 " " $3 $4}END{ print ""}' "$f" 2>/dev/null; done | sort -k2 -n -r | head -20
# View swap usage trends
$ sar -W 1 5
zram: In-Memory Compression Alternative to Swap
zram creates a compressed block device in memory, suitable for memory-constrained scenarios where disk swap is undesirable:
# Create a zram device
$ modprobe zram num_devices=1
$ echo lz4 > /sys/block/zram0/comp_algorithm
$ echo 4G > /sys/block/zram0/disksize
$ mkswap /dev/zram0
$ swapon -p 10 /dev/zram0
# View compression ratio
$ cat /sys/block/zram0/mm_stat
OOM Killer Principles
Trigger Conditions
When the kernel cannot allocate memory (even after reclamation), the OOM Killer is triggered, selecting a “best victim” process to kill in order to free memory.
OOM Scoring Mechanism
Each process has an oom_score (0-1000); the higher the score, the more likely it is to be killed:
# View a process's oom_score
$ cat /proc/$PID/oom_score
# View oom_score_adj (-1000 to 1000)
$ cat /proc/$PID/oom_score_adj
Scoring factors:
| Factor | Impact | Description |
|---|---|---|
| Memory usage | Positive correlation | More usage = higher score |
| root process | Negative correlation | Kernel tends to protect root processes |
| Number of child processes | Negative correlation | Processes with children are more protected |
| oom_score_adj | Direct adjustment | -1000 = fully immune, 1000 = killed first |
Production OOM Protection
# Protect critical processes (e.g., database)
$ echo -1000 > /proc/$DB_PID/oom_score_adj
# Configure in systemd
cat > /etc/systemd/system/mysqld.service.d/oom.conf << 'EOF'
[Service]
OOMScoreAdjust=-1000
EOF
cgroup v2 OOM Control
# Set memory limit and OOM behavior in cgroup v2
$ echo 4G > /sys/fs/cgroup/app.memory.max
$ echo 4.5G > /sys/fs/cgroup/app.memory.high # Soft limit, reclaim begins above this
# Configure OOM to kill the entire cgroup (not just a single process)
$ echo 1 > /sys/fs/cgroup/app.memory.oom.group
OOM Log Analysis
# OOM records in kernel logs
$ journalctl -k | grep -A 30 "Out of memory"
# Typical OOM log
# Out of memory: Killed process 12345 (java) total-vm:8G, anon-rss:6G, file-rss:100M
Key field interpretation:
total-vm: Total virtual memory of the processanon-rss: Anonymous memory (actual physical memory used)file-rss: Physical memory from mapped files
cgroup v2 Memory Control
cgroup v1 vs v2 Comparison
| Feature | cgroup v1 | cgroup v2 |
|---|---|---|
| Hierarchy | Independent per subsystem | Unified hierarchy |
| Memory accounting | Coarse-grained | Fine-grained (file/anon breakdown) |
| Swap control | Requires extra config | Native support |
| OOM management | Limited | Priority and group kill support |
| Kernel threads | Hard to control | Controllable |
Key Memory Control Files
# Memory hard limit
/sys/fs/cgroup/<path>/memory.max
# Memory soft limit (reclaim begins above this)
/sys/fs/cgroup/<path>/memory.high
# Swap limit
/sys/fs/cgroup/<path>/memory.swap.max
# Current memory usage
/sys/fs/cgroup/<path>/memory.current
# Memory peak
/sys/fs/cgroup/<path>/memory.peak
# Detailed statistics
/sys/fs/cgroup/<path>/memory.stat
# Event notifications
/sys/fs/cgroup/<path>/memory.events
Container Memory Limit Example
# Create a cgroup for the application
$ mkdir /sys/fs/cgroup/myapp
# Limit memory to 2G, swap to 1G
$ echo 2G > /sys/fs/cgroup/myapp/memory.max
$ echo 1G > /sys/fs/cgroup/myapp/memory.swap.max
# Soft limit 1.5G (reclaim begins above, but no kill)
$ echo 1536M > /sys/fs/cgroup/myapp/memory.high
# Add process to cgroup
$ echo $PID > /sys/fs/cgroup/myapp/cgroup.procs
# View events
$ cat /sys/fs/cgroup/myapp/memory.events
low 0
high 0 # Number of times memory.high was exceeded
max 0 # Number of times memory.max was reached
oom 0 # Number of OOM events
oom_kill 0 # Number of processes killed by OOM
Memory Leak Troubleshooting
Symptom Identification
Typical signs of memory leaks:
- RSS keeps growing without receding
freeshows available memory continuously declining- Process gets killed by OOM Killer, restarts, then grows again
VmRSSin/proc/$PID/statuskeeps increasing
Diagnostic Toolchain
1. Basic Monitoring
# Real-time process memory
$ top -p $PID
$ ps aux --sort=-%mem | head -20
# Detailed process memory map
$ cat /proc/$PID/status | grep -E "VmRSS|VmSize|VmData|VmStk|VmExe"
# Process memory mapping
$ pmap -x $PID | tail -5
2. /proc/smaps Analysis
# View process memory mapping details
$ cat /proc/$PID/smaps_rollup
Rss: 1048576 kB
Pss: 987654 kB # Proportional share
Shared_Clean: 12345 kB
Shared_Dirty: 6789 kB
Private_Clean: 4567 kB
Private_Dirty: 1024356 kB # Watch if this keeps growing
3. eBPF Memory Allocation Tracing
# Trace malloc calls using bcc tools
$ /usr/share/bcc/tools/memleak -p $PID
# Trace slab allocation
$ /usr/share/bcc/tools/slabratetop
# Trace page allocation
$ /usr/share/bcc/tools/oomkill
4. pstack/strace Analysis
# View process stack
$ pstack $PID
# Trace memory-related system calls
$ strace -e trace=mmap,brk,munmap,mprotect -p $PID
Java Application Memory Leak Investigation
# View Java heap usage
$ jmap -heap $PID
# Dump heap
$ jmap -dump:format=b,file=/tmp/heapdump.hprof $PID
# Analyze with MAT (offline)
$ jhat -J-Xmx4G /tmp/heapdump.hprof
Go Application Memory Leak Investigation
# View Go memory stats
$ curl http://localhost:6060/debug/pprof/heap > heap.prof
# Analyze with pprof
$ go tool pprof heap.prof
(pprof) top 10
(pprof) list <function_name>
(pprof) web # Generate call graph
slab/shmem Tuning
slab Mechanism
slab is the kernel’s caching mechanism for managing small object memory allocation. dentry cache and inode cache are the largest slab consumers.
# View slab usage
$ cat /proc/meminfo | grep -E "Slab|SReclaimable|SUnreclaim"
Slab: 234567 kB
SReclaimable: 189234 kB # Reclaimable
SUnreclaim: 45333 kB # Unreclaimable
# Detailed slab statistics
$ slabtop -o | head -20
Common slab Caches
| Cache Name | Description | Tuning Direction |
|---|---|---|
| dentry | Directory entry cache | vfs_cache_pressure |
| inode_cache | inode cache | vfs_cache_pressure |
| buffer_head | Block device buffer | Reduce I/O |
| task_struct | Process descriptor | Reduce process count |
| tcp_bind_bucket | TCP port binding | Reduce connection count |
| kmalloc-* | General purpose | No tuning needed |
shmem (Shared Memory) Tuning
# View shmem usage
$ cat /proc/meminfo | grep Shmem
Shmem: 45678 kB
# tmpfs defaults to 50% of memory
$ mount | grep tmpfs
tmpfs on /dev/shm type tmpfs (rw,nosuid,nodev)
# Limit tmpfs size
$ mount -o remount,size=1G /dev/shm
Production Case: Excessive dentry Cache Usage
Symptom: 128GB RAM machine, free shows only 10GB available, but total RSS of all processes is under 20GB.
Investigation:
$ cat /proc/meminfo | grep -E "Cached|SReclaimable"
Cached: 45678901 kB
SReclaimable: 38234567 kB # 38GB reclaimable slab
$ slabtop -o | head -10
OBJS ACTIVE USE OBJ SIZE SLABS OBJ/SLAB CACHE SIZE NAME
12345678 12000000 97% 0.19K 567890 21 2271560K dentry
3456789 3000000 86% 0.66K 98765 4 395060K inode_cache
Root cause: The application frequently creates/deletes large numbers of temporary files, causing dentry cache bloat.
Solution:
# Temporary: manual reclaim
$ echo 2 > /proc/sys/vm/drop_caches
# Long-term: adjust vfs_cache_pressure
$ sysctl -w vm.vfs_cache_pressure=200
# Or limit dentry cache size (requires kernel support)
$ sysctl -w vm.dentry_cache_limit=100000000
Real-World Cases
Case 1: Java Application OOM Investigation
Environment: 4C8G server running Java microservice (-Xmx4G)
Symptom: Service OOM-restarts after 3 days, but JVM heap usage is normal (< 60%).
Investigation:
# 1. Check OOM logs
$ journalctl -k | grep "Out of memory"
# Out of memory: Killed process 12345 (java) total-vm:12G, anon-rss:6.5G
# 2. Process RSS reached 6.5G, but -Xmx4G, meaning non-heap memory is 2.5G
# 3. Analyze with /proc/smaps
$ cat /proc/12345/smaps_rollup
Rss: 6553600 kB
Private_Dirty: 5242880 kB # 5G private dirty pages
# 4. pmap analysis
$ pmap -x 12345 | sort -k3 -n -r | head -10
# Found many 64MB anon mappings → thread stacks
# 5. Check thread count
$ ls /proc/12345/task | wc -l
8200 # 8200 threads
# 6. Each thread stack defaults to 1MB, 8200 threads ≈ 8G
Root cause: Thread pool misconfigured (unlimited thread creation), each thread’s 1MB stack caused non-heap memory bloat.
Solution:
- Limit thread pool max threads
- Reduce thread stack size:
-Xss256k - Configure OOM protection:
OOMScoreAdjust=-500
Case 2: cgroup Memory Limit Killing Redis
Environment: Redis running in a Kubernetes Pod, limits set to memory: 2Gi
Symptom: Redis periodically gets OOMKilled.
Investigation:
# 1. Check Kubernetes events
$ kubectl describe pod redis-xxx
# Last State: Terminated, Reason: OOMKilled, Exit Code: 137
# 2. Redis INFO memory
$ redis-cli INFO memory
used_memory:1.2G
used_memory_rss:1.9G # RSS near 2G limit
mem_fragmentation_ratio:1.58 # Fragmentation ratio 1.58
Root cause: Redis memory fragmentation caused RSS to far exceed used_memory, hitting the cgroup limit.
Solution:
- Enable Redis active defragmentation:
activedefrag yes - Adjust maxmemory to 1.5G (leave 500MB for fragmentation and overhead)
- Use
jemallocinstead of default allocator
Case 3: Memory Binding Under NUMA Architecture
Environment: Dual-socket CPU server (2 × 32 cores), 256GB RAM, running PostgreSQL
Symptom: Certain queries have unstable latency, occasionally spiking to 10x or more.
Investigation:
# 1. Check NUMA topology
$ numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 ... 31
node 0 size: 128GB
node 1 cpus: 32 33 ... 63
node 1 size: 128GB
# 2. Check PostgreSQL process NUMA memory distribution
$ numastat -p $(pidof postgres | awk '{print $1}')
Per-node process memory usage (in MBs)
PID Node 0 Node 1 Total
12345 82000 21000 103000 # Most memory on Node 0
# 3. Check NUMA hit/miss statistics
$ numastat
Node 0 Node 1
Hit 1234567 234567
Miss 1234 56789 # Node 1 has high miss count
Root cause: PostgreSQL’s multi-process model caused uneven memory allocation, with cross-NUMA access increasing latency.
Solution:
# Option 1: Bind to specific NUMA node with numactl
$ numactl --cpunodebind=0 --membind=0 postgres ...
# Option 2: Configure interleave mode
$ numactl --interleave=all postgres ...
# Option 3: Disable zone_reclaim via kernel parameter
$ sysctl -w vm.zone_reclaim_mode=0
Common Memory Monitoring Command Quick Reference
# System-level memory overview
$ free -h
$ vmstat 1
$ sar -r 1
# Process-level memory
$ ps aux --sort=-%mem | head
$ pmap -x $PID
$ cat /proc/$PID/status | grep -E "Vm|RSS"
# Kernel memory
$ cat /proc/meminfo
$ slabtop
$ cat /proc/zoneinfo | head -40
# NUMA
$ numastat
$ numactl --hardware
# Real-time tracing
$ /usr/share/bcc/tools/memleak -p $PID
$ /usr/share/bcc/tools/oomkill
$ /usr/share/bcc/tools/slabratetop
Summary
Linux memory management is a multi-layered complex system — from hardware NUMA topology to kernel zone allocators, from Page Cache to Swap, from process address space to cgroup limits — each layer has corresponding tuning knobs. Key takeaways:
- Understand that Page Cache is a friend, not an enemy: Low available memory does not equal memory shortage;
CachedandSReclaimableare automatically freed when needed. - Swap is not necessarily bad: Low swappiness combined with zram can provide a buffer when memory is tight.
- OOM Killer is the last resort: Proactive control via cgroup v2’s
memory.maxandmemory.highis far more elegant than waiting for the OOM Killer to intervene. - cgroup v2 is the cornerstone of modern memory management: Unified hierarchy, fine-grained accounting, event notifications — the standard for memory control in containerized environments.
- Memory leak troubleshooting requires a toolchain: From
free/topfor symptom identification, tosmaps/pmapfor distribution analysis, to eBPF for allocation tracing — layer by layer. - NUMA awareness is mandatory for large-memory servers: Cross-node memory access latency can be 2-3x higher; latency-sensitive applications like databases must do NUMA binding.
Core principle of memory tuning: measure first, then tune. Before modifying any parameter, collect baseline data with
sar/vmstat/numastat, then compare results after changes — avoid tuning by intuition.