How Linux Actually Allocates Memory

The Linux kernel uses a virtual memory system where every process sees a private address space backed by physical RAM pages, anonymous mappings, or file-backed pages. The Memory Management Unit (MMU) translates virtual addresses to physical via page tables, and the kernel's page allocator hands out 4KB pages (or 2MB/1GB hugepages where configured).

There are two broad categories of memory consumption you need to distinguish: anonymous memory and page cache. Anonymous memory is heap, stack, and mmap regions not backed by a file - this is what your application actually owns. Page cache is file data the kernel cached in RAM to speed up I/O. The kernel will reclaim page cache under memory pressure without any application involvement.

Run `cat /proc/meminfo` and look at these specific fields: `MemTotal`, `MemFree`, `MemAvailable`, `Cached`, `Buffers`, `SwapCached`, `Active(anon)`, `Inactive(anon)`, `Active(file)`, `Inactive(file)`, and `Shmem`. The number that actually matters for 'is this server under pressure' is `MemAvailable`, not `MemFree`. `MemAvailable` is the kernel's estimate of how much RAM can be made available without swapping, accounting for reclaimable cache and memory that can be freed from kernel slab caches.

cat /proc/meminfo | grep -E 'MemTotal|MemFree|MemAvailable|Cached|Buffers|SwapCached|Active|Inactive|Shmem|Dirty|Writeback'

Reading Memory Stats Without Being Misled

`free -h` is fine for a quick glance but the output format changed in procps-ng 3.3.10 (shipped in RHEL 8+, Debian 10+). The 'available' column maps directly to `/proc/meminfo`'s `MemAvailable`. The 'buff/cache' column combines `Buffers` + `Cached` + `Shmem` - all of which are reclaimable under pressure.

For production diagnosis, `vmstat 1 5` gives you a five-second rolling picture. The columns `si` and `so` (swap in, swap out) are the critical ones - any nonzero values during normal operation mean you have genuine memory pressure and processes are getting paged out. `bi` and `bo` (blocks in/out) tell you about disk I/O from page cache activity.

`smem` gives per-process proportional set size (PSS), which is more honest than RSS for processes that share memory via shared libraries or shared mappings. PSS divides shared pages proportionally among all processes that map them. On a server with 50 Java microservices sharing a JVM base image via Copy-on-Write, RSS will wildly overcount memory usage - PSS won't.

For a sorted view of actual memory consumers, run the smem command below. If smem isn't installed, `awk` against `/proc/*/status` works on any system without extra packages.

# smem sorted by PSS descending
smem -s pss -r -k | head -20

# Without smem: parse /proc directly
awk '/^VmRSS/ {rss=$2} /^VmPSS/ {pss=$2} /^Name/ {name=$2} pss{print pss, rss, name; rss=0; pss=0}' /proc/*/status 2>/dev/null | sort -rn | head -20

The Page Cache: Friend or Problem

The page cache is one of the biggest performance wins Linux gives you for free. When a process reads a file, the kernel stores the pages in RAM. The next read - from any process - hits RAM instead of disk. For a database doing repetitive index scans, this can be the difference between microsecond and millisecond latency.

The two metrics to watch are `Dirty` and `Writeback` in `/proc/meminfo`. `Dirty` pages are cached writes that haven't been flushed to disk yet. `Writeback` pages are in the process of being written. High `Dirty` values (above several hundred MB on a system not doing bulk writes) combined with high I/O wait indicate your writeback throttle is misconfigured.

The kernel controls dirty page writeback via these tunables in `/proc/sys/vm/`:

- `dirty_ratio`: percentage of total RAM at which a process doing writes is blocked until pages are flushed (default 20) - `dirty_background_ratio`: percentage at which background writeback starts (default 10) - `dirty_expire_centisecs`: how old a dirty page can be before the kernel flushes it (default 3000 = 30 seconds) - `dirty_writeback_centisecs`: how often the writeback kernel thread wakes up (default 500 = 5 seconds)

For a database server with a fast NVMe array, you typically want lower dirty ratios to avoid big write stalls. For a bulk-ingest pipeline where you want to batch writes, higher values reduce I/O amplification. We tested a PostgreSQL server on NVMe: dropping `dirty_ratio` from 20 to 5 and `dirty_background_ratio` from 10 to 2 cut maximum write latency spikes by 60% under sustained INSERT load.

# View current dirty page settings
sysctl vm.dirty_ratio vm.dirty_background_ratio vm.dirty_expire_centisecs vm.dirty_writeback_centisecs

# Apply tuned values for database workload (add to /etc/sysctl.d/99-memory.conf)
cat > /etc/sysctl.d/99-memory.conf << 'EOF'
vm.dirty_ratio = 5
vm.dirty_background_ratio = 2
vm.dirty_expire_centisecs = 1000
vm.dirty_writeback_centisecs = 500
EOF
sysctl -p /etc/sysctl.d/99-memory.conf
// advertisement

Swap: What It Actually Does in 2026

Swap is not a fallback for when you run out of RAM. That framing causes misconfigured systems. Swap serves two distinct purposes: it gives the kernel a place to move cold anonymous pages (freeing RAM for hot page cache), and it provides a safety net against OOM kills for occasional memory spikes.

The `vm.swappiness` knob (0-200 on kernel 5.8+, 0-100 on older kernels) controls how aggressively the kernel moves anonymous pages to swap versus evicting page cache. A value of 0 does not disable swap - it just means the kernel strongly prefers evicting file cache over swapping anonymous pages. On kernel 5.8+ with the updated swappiness semantics, a value of 200 means anonymous pages are treated as twice as swappable as file pages.

For a server where you want to keep application data in RAM and accept slower file I/O: set swappiness to 10. For a desktop or server doing mixed workloads where preventing OOM is the priority: keep it at 60. For a Redis or Memcached server where you absolutely cannot have the process swapped: set swappiness to 1 (not 0 - 0 on pre-5.8 kernels can cause OOM on some workloads when swap exists).

zswap, enabled by default on many distributions since kernel 6.1, compresses swap pages in RAM before writing them to disk. On our test server running Ubuntu 24.04 with a CPU that has AVX2, zswap with the lz4 compressor reduced swap I/O by ~70% during a memory spike workload. Check if it's active with `cat /sys/module/zswap/parameters/enabled`. If your swap device is slow (spinning disk, network storage), zswap is a significant win. If your swap is NVMe, the benefit is smaller.

# Check swap usage and activity
swapon --show
vmstat 1 5 | awk 'NR>2 {print "si="$7, "so="$8}'

# Check zswap status
grep -r . /sys/module/zswap/parameters/ 2>/dev/null

# Set swappiness persistently
echo 'vm.swappiness = 10' >> /etc/sysctl.d/99-memory.conf
sysctl vm.swappiness=10

# Enable zswap with lz4 at boot (add to kernel cmdline or sysfs)
echo lz4 > /sys/module/zswap/parameters/compressor
echo 20 > /sys/module/zswap/parameters/max_pool_percent

Creating and Sizing Swap Correctly

The old rule of 'swap = 2x RAM' comes from an era when 256MB RAM was large. In 2026, that rule is wrong for most servers. Size swap based on what you need it to do.

If swap is for cold-page offloading (freeing RAM for cache): 2-4GB is sufficient on most servers regardless of RAM size. The kernel rarely needs to move more than a few GB of cold anonymous pages.

If swap is a safety net against OOM: size it to cover the maximum expected memory spike above your normal working set. On a server with 32GB RAM running applications that peak at 35GB during batch jobs, 4-8GB swap handles the overage.

If hibernate is required: swap must be at least the size of RAM.

For swap files versus swap partitions: on modern kernels (4.9+), swap files on ext4 or xfs perform identically to swap partitions. On btrfs, swap files require special handling (no-copy-on-write attribute). The commands below create a 4GB swap file.

# Create a 4GB swap file
dd if=/dev/zero of=/swapfile bs=1M count=4096 status=progress
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile

# Make permanent
echo '/swapfile none swap sw 0 0' >> /etc/fstab

# Verify
swapon --show
cat /proc/swaps

OOM Killer: How It Chooses and How to Control It

When the kernel cannot reclaim enough memory and swap is exhausted, the OOM killer runs. It assigns each process an `oom_score` (0-1000) based on memory consumption relative to total RAM, then kills the highest-scoring process. You can see current scores with `cat /proc/*/oom_score` or the command below.

Two controls matter: `oom_score_adj` and `oom_adj` (the latter is deprecated, don't use it). `oom_score_adj` ranges from -1000 to +1000. Setting it to -1000 makes a process immune to OOM kills. Setting it to +1000 makes it the first target. For critical daemons (sshd, your primary database), setting `oom_score_adj` to -500 or lower gives them strong protection without the immunity that -1000 provides (immunity can cause the kernel to kill other processes that would have been better targets).

In systemd units, use `OOMScoreAdjust=-500` in the `[Service]` section. For containers, Docker/Podman expose this as `--oom-score-adj`.

When an OOM kill happens, the kernel logs it to dmesg with full details. The `oom_kill_process` line shows which process was killed, its score, and why. If your OOM killer is firing during automation pipelines, DevOps teams running AI-assisted workflows on platforms like taskbotshub.ai often add OOM event hooks that trigger memory diagnostics before the kill completes.

# View OOM scores for all processes, sorted
for pid in /proc/[0-9]*/; do
  pid_num=$(basename $pid)
  comm=$(cat $pid/comm 2>/dev/null)
  score=$(cat $pid/oom_score 2>/dev/null)
  adj=$(cat $pid/oom_score_adj 2>/dev/null)
  echo "$score $adj $pid_num $comm"
done | sort -rn | head -20

# Protect a specific PID from OOM
echo -500 > /proc/$(pgrep postgres | head -1)/oom_score_adj

# In a systemd unit
# [Service]
# OOMScoreAdjust=-500

# Check recent OOM kills
dmesg -T | grep -E 'Out of memory|oom_kill|killed process'
// advertisement

Huge Pages: When They Help and When They Don't

Standard Linux pages are 4KB. With 32GB RAM, the kernel manages 8 million page table entries. For processes with large memory footprints (databases, JVMs, HPC applications), this creates TLB pressure - the Translation Lookaside Buffer cache is small, and misses are expensive.

Hugepages (2MB on x86_64) reduce TLB pressure by a factor of 512. A PostgreSQL server with a 16GB shared_buffers using 2MB hugepages needs only 8,192 TLB entries instead of 4 million for that region.

Linux offers two hugepage mechanisms. Static hugepages are pre-allocated at boot and reserved - applications must explicitly request them via `mmap(MAP_HUGETLB)` or by mapping `/dev/hugepages`. Transparent Huge Pages (THP) are automatic - the kernel promotes 4KB pages to 2MB pages behind the scenes.

THP is enabled by default (`/sys/kernel/mm/transparent_hugepage/enabled = always`) but causes latency spikes for latency-sensitive workloads because page promotion and defragmentation happen asynchronously. For Redis, MongoDB, and many databases, the standard recommendation is to set THP to `madvise` (only use THP where applications explicitly request it) or `never`.

For PostgreSQL, the better approach is static hugepages combined with THP set to `madvise`. For JVMs with `-XX:+UseTransparentHugePages`, keep THP at `always` or `madvise`. On our test server, disabling THP on a Redis instance reduced p99 latency from 4.2ms to 1.1ms under a mixed workload.

# Check THP status
cat /sys/kernel/mm/transparent_hugepage/enabled
cat /sys/kernel/mm/transparent_hugepage/defrag

# Disable THP for latency-sensitive workloads
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled
echo defer+madvise > /sys/kernel/mm/transparent_hugepage/defrag

# Persist via rc.local or systemd unit (add to /etc/rc.local or a oneshot service)
# For static hugepages (example: reserve 4096 x 2MB = 8GB)
echo 4096 > /proc/sys/vm/nr_hugepages
# Or persist:
echo 'vm.nr_hugepages = 4096' >> /etc/sysctl.d/99-memory.conf

# PostgreSQL hugepage config (postgresql.conf)
# huge_pages = on
# shared_buffers = 8GB

Memory Pressure in Containers and cgroups v2

Containers don't change how the kernel manages memory - they restrict it via cgroups. With cgroups v2 (default since kernel 5.10+, used by Docker 20.10+ and Podman 4.0+ when the host supports it), memory limits are enforced via `memory.max` and soft limits via `memory.high`.

`memory.high` is the pressure point: when a container exceeds this, the kernel aggressively reclaims its memory and the process gets throttled. `memory.max` is the hard limit - exceeding it triggers OOM kill within the container's scope. This two-level system lets you express 'this container should target 2GB but can burst to 3GB' cleanly.

A common misdiagnosis: a container hitting `memory.high` repeatedly shows up as high CPU usage (the reclaim work runs in the process context). If your container process shows inexplicably high CPU and the application isn't doing heavy computation, check cgroup memory pressure.

The cgroup memory pressure interface at `/sys/fs/cgroup//memory.pressure` reports slow, medium, and full pressure stalls using PSI (Pressure Stall Information). A `full` value above 0 means processes stalled completely waiting for memory reclaim - that's a hard signal to add RAM or reduce the workload. For DevOps teams managing fleet-scale container deployments, automating PSI monitoring via a tool like taskbotshub.ai can surface memory pressure events before they become outages.

# Find cgroup for a container (Docker example)
DOCKER_ID=$(docker inspect --format '{{.Id}}' mycontainer)
CGROUP_PATH="/sys/fs/cgroup/system.slice/docker-${DOCKER_ID}.scope"

# View memory limits and usage
cat ${CGROUP_PATH}/memory.max
cat ${CGROUP_PATH}/memory.current
cat ${CGROUP_PATH}/memory.high

# View PSI memory pressure
cat ${CGROUP_PATH}/memory.pressure

# Set limits (Docker run flags map to these)
docker run --memory=2g --memory-reservation=1g myimage

# Equivalent cgroup v2 direct write (for systemd-managed units)
systemctl set-property myservice.service MemoryMax=2G MemoryHigh=1G

Diagnosing Memory Leaks and Gradual Consumption

A process growing in RSS over days without releasing memory is either leaking or caching data without a bound. The first step is separating leak from intentional growth.

Track a process's `VmRSS` and `VmPSS` from `/proc//status` over time. If PSS grows monotonically without plateauing, it's a strong leak signal. If RSS grows but PSS stays flat, shared memory is accumulating (check `VmShr` in `/proc//status`).

`valgrind --leak-check=full` is the definitive tool for C/C++ applications but the runtime overhead is 10-50x. For production diagnosis, use `heaptrack` (much lower overhead) or `jemalloc`'s built-in profiling by setting `MALLOC_CONF=prof:true,lg_prof_interval:30` before running the application.

For kernel-side slab cache leaks (visible as growing `Slab` in `/proc/meminfo`), use `slabtop` to identify which cache is growing. Slab caches for dentries and inodes are the most common culprits on systems doing heavy filesystem operations or running many containers with overlayfs.

`/proc/buddyinfo` shows the buddy allocator's free page distribution per NUMA node and zone. Heavily fragmented memory (all small orders, no large orders available) causes allocation failures even when `MemFree` looks adequate. The solution is `echo 1 > /proc/sys/vm/compact_memory` to trigger memory compaction - this is disruptive on systems under load, so schedule it during low-traffic windows.

# Track a process RSS and PSS over time
PID=$(pgrep -o myapp)
while true; do
  awk '/VmRSS|VmPSS|VmShr/{printf "%s ", $2}' /proc/$PID/status
  echo "$(date +%s)"
  sleep 60
done

# Slab cache analysis
slabtop -o | head -30
cat /proc/meminfo | grep -E 'Slab|SReclaimable|SUnreclaim'

# Buddy allocator fragmentation
cat /proc/buddyinfo

# Trigger compaction (use carefully)
echo 1 > /proc/sys/vm/compact_memory

# Drop caches manually (for testing, not production)
# 1=page cache, 2=dentries+inodes, 3=all
echo 3 > /proc/sys/vm/drop_caches
// advertisement

NUMA Awareness and Memory Locality

On multi-socket servers (two or more physical CPUs), memory is not uniformly accessible. Each CPU has local RAM with ~100ns access latency and remote RAM (on the other socket) with ~200ns latency. This Non-Uniform Memory Access (NUMA) topology can tank performance if processes are allocated remote memory.

Check your NUMA topology with `numactl --hardware`. The `numastat` command shows per-node allocation hits and misses. A high `numa_miss` count (memory allocated on a non-local node) means your applications are paying the remote access penalty.

For database servers, bind the process to a NUMA node using `numactl --cpunodebind=0 --membind=0 postgres`. For Java applications, the JVM NUMA-aware allocator is enabled with `-XX:+UseNUMA`. For applications you can't control directly, the kernel's NUMA balancing (`/proc/sys/kernel/numa_balancing = 1`) automatically migrates pages toward the CPUs accessing them, at the cost of some overhead from page scanning and migration.

On a 2-socket Xeon server in our lab with 256GB RAM total, enabling numactl binding for a MySQL instance reduced query p99 latency by 18% compared to the default non-NUMA-aware startup. The gain scales with the memory access intensity of the workload.

# View NUMA topology
numactl --hardware
numa_maps=$(numastat -p $(pgrep postgres | head -1) 2>/dev/null)
echo "$numa_maps"

# Check NUMA hit/miss statistics
numastat

# Run a process bound to NUMA node 0
numactl --cpunodebind=0 --membind=0 -- /usr/bin/postgres -D /var/lib/postgresql/data

# Check and enable NUMA balancing
cat /proc/sys/kernel/numa_balancing
echo 1 > /proc/sys/kernel/numa_balancing

# View per-process NUMA page distribution
cat /proc/$(pgrep postgres | head -1)/numa_maps | head -20