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:

LayerRangeVisible to Userspace
User space0x000000000000 ~ 0x00007FFFFFFFFFFFYes
Non-canonical area0x0000800000000000 ~ 0xFFFF7FFFFFFFFFFFNo (hole)
Kernel space0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFFNo

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"
ZonePurposeTypical Scenario
DMABelow 16MB, legacy ISA device DMARarely used
DMA32Below 4GB, 32-bit DMA devicesOld hardware
NormalAbove 4GB, most memory allocationsPrimary use
MovableMigratable pages, supports memory hot-plugVirtualization/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:

ScenarioTHP PolicyReason
Database (MySQL/PostgreSQL)neverRandom access pattern, huge pages waste memory
Virtualization (KVM)alwaysContiguous memory access, reduces TLB misses
Container runtimemadviseBalances performance and memory waste
General web servermadviseDefault 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:

Scenariodirty_background_ratiodirty_ratioNotes
General server1020Default, balanced
Database server510Reduces burst I/O peaks
Large memory machine (>128GB)15Use bytes instead of ratio
Write-intensive1530Allows more caching

For large memory machines, use _bytes instead 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_caches causes a brief I/O spike; use with caution in production. Run sync before 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
ScenarioswappinessReason
Database server1Swap causes latency spikes
Container host10Prevents containers being slowed by swap
Desktop system60Default
Embedded device100Extremely 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:

FactorImpactDescription
Memory usagePositive correlationMore usage = higher score
root processNegative correlationKernel tends to protect root processes
Number of child processesNegative correlationProcesses with children are more protected
oom_score_adjDirect 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 process
  • anon-rss: Anonymous memory (actual physical memory used)
  • file-rss: Physical memory from mapped files

cgroup v2 Memory Control

cgroup v1 vs v2 Comparison

Featurecgroup v1cgroup v2
HierarchyIndependent per subsystemUnified hierarchy
Memory accountingCoarse-grainedFine-grained (file/anon breakdown)
Swap controlRequires extra configNative support
OOM managementLimitedPriority and group kill support
Kernel threadsHard to controlControllable

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
  • free shows available memory continuously declining
  • Process gets killed by OOM Killer, restarts, then grows again
  • VmRSS in /proc/$PID/status keeps 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 NameDescriptionTuning Direction
dentryDirectory entry cachevfs_cache_pressure
inode_cacheinode cachevfs_cache_pressure
buffer_headBlock device bufferReduce I/O
task_structProcess descriptorReduce process count
tcp_bind_bucketTCP port bindingReduce connection count
kmalloc-*General purposeNo 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 jemalloc instead 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:

  1. Understand that Page Cache is a friend, not an enemy: Low available memory does not equal memory shortage; Cached and SReclaimable are automatically freed when needed.
  2. Swap is not necessarily bad: Low swappiness combined with zram can provide a buffer when memory is tight.
  3. OOM Killer is the last resort: Proactive control via cgroup v2’s memory.max and memory.high is far more elegant than waiting for the OOM Killer to intervene.
  4. cgroup v2 is the cornerstone of modern memory management: Unified hierarchy, fine-grained accounting, event notifications — the standard for memory control in containerized environments.
  5. Memory leak troubleshooting requires a toolchain: From free/top for symptom identification, to smaps/pmap for distribution analysis, to eBPF for allocation tracing — layer by layer.
  6. 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.