This chapter assumes the basic virtual-memory and OOM-killer vocabulary already covered in this site's Linux & Networking Fundamentals series — this chapter goes considerably deeper (page tables, the TLB, huge pages, the page cache, cgroup-level memory limits) rather than re-covering that ground; the two are cross-linked throughout rather than duplicated.
Table of Contents#
- Why Memory Deserves Its Own Deep Dive
- Physical Memory — The Actual Hardware
- Slab Allocation — How the Kernel Manages Its Own Memory
- Page Tables — How Virtual Addresses Become Physical Ones
- The TLB — Why Virtual Memory Isn't Free
- Huge Pages — Trading Flexibility for Fewer TLB Misses
- The Page Cache — Why "Free" Memory Isn't What It Looks Like
- Dirty Pages and Writeback
- Memory Fragmentation and Compaction
- The Kernel's Reclaim Path — kswapd and Direct Reclaim
- Swap and vm.swappiness — Tuning Reclaim Behavior
- Memory Overcommit — Why malloc() Can Lie
- mmap() and Memory-Mapped Files — Blurring the Line Between Memory and Storage
- Copy-on-Write and fork()
- RSS vs. VSZ vs. PSS — Reading Memory Usage Correctly
- NUMA Memory Locality, Revisited
- Memory Bandwidth — The Resource Nobody Names
- OOM Score and oom_score_adj — Influencing Victim Selection
- cgroup Memory Limits and the Cgroup OOM Killer
- Reading Memory Behavior on a Real Machine
- How These Concepts Show Up on the Cloud Bill
- Common Mistakes and Interview Traps
- Worked Practice Problems
- Summary and What's Next
Why Memory Deserves Its Own Deep Dive#
Part 1 and Part 2 both treated memory access as an instantaneous, resolved fact — a core reads an address, the value is simply there. In reality, every single memory access a running thread makes passes through a substantial amount of kernel and hardware machinery first: translating a virtual address to a physical one, checking whether that page is even resident in RAM right now, and potentially triggering real, visible work (a page fault, a disk read, memory reclaim) before the access can complete at all. This chapter opens that machinery up.
The running example remains this site's fictional e-commerce platform — checkout-service, catalog-service, inventory-service — used throughout this series and across this site's Terraform, Kubernetes, Observability, and Incident Management series.
Note
This chapter deliberately goes past what this site's Linux & Networking Fundamentals series already covers (process/virtual memory basics, the system-wide OOM killer) into the mechanisms underneath: how a virtual address actually resolves to physical RAM, why that resolution has a real performance cost, how the kernel decides what to evict under pressure, and how cgroup-level memory limits — the mechanism behind every Kubernetes memory limit — interact with all of it. Cross-links point back to that series' chapter rather than repeating its content.
Physical Memory — The Actual Hardware#
Physical RAM is organized into fixed-size pages — 4 KB on essentially every mainstream Linux platform, x86-64 and ARM alike — the fundamental unit the kernel's memory manager allocates, tracks, and reclaims in. A process never directly addresses physical RAM at all; everything a program touches is a virtual address, translated to a physical one by hardware on every single access.
Why pages, specifically, rather than tracking memory byte-by-byte or as one contiguous block per process: a fixed page size lets the kernel manage physical memory as simple, interchangeable units — any page frame can hold any page, allocation and deallocation become bookkeeping operations on a free-list rather than complex contiguous-space management, and it's exactly what makes virtual memory's core trick possible: a process's logically contiguous address space can be backed by physically scattered page frames, with the mapping between the two handled entirely by the translation machinery covered next.
| Concept | What it means |
|---|---|
| Page | The fixed-size (4 KB, standard) unit of memory allocation and translation |
| Page frame | A physical page-sized slot in RAM that can hold any page |
| Page fault | The CPU trapping to the kernel because a virtual address's page isn't currently mapped to a physical frame |
Not every page fault is equally expensive, and the distinction is a genuinely useful diagnostic signal in its own right:
| Fault type | What happens | Relative cost |
|---|---|---|
| Minor fault | The page exists in memory already (e.g., a shared library page another process already loaded, or a page the kernel is lazily mapping in) — the kernel just updates this process's page table to point at it | Cheap — no disk I/O, just kernel bookkeeping |
| Major fault | The page genuinely isn't in memory anywhere and must be read from disk (a swapped-out page, or a memory-mapped file's not-yet-read portion) | Expensive — a real disk I/O round-trip, exactly the kind of stall Part 4's storage-latency coverage quantifies |
ps -o min_flt,maj_flt or /usr/bin/time -v report both counts per process directly — a process with a climbing major-fault count is a strong, specific signal of either swap activity or first-touch access to a large memory-mapped file, worth checking before assuming an unexplained stall is purely CPU- or lock-related.
Slab Allocation — How the Kernel Manages Its Own Memory#
Everything covered in this chapter so far describes memory from an application process's point of view — but the kernel itself constantly allocates and frees small, fixed-size objects of its own (a task_struct per process, a network buffer, a filesystem inode cache entry), far too frequently and at too small a granularity to efficiently use the page allocator directly for each one. The slab allocator (and its modern descendant, SLUB) solves this: pre-allocating pages and carving them into pools of same-sized, same-type objects, so allocating or freeing one of these small kernel structures becomes a fast pool operation instead of a full page-allocation event.
Why this matters operationally, not just as kernel trivia: kernel memory tracked in slab caches shows up as its own distinct category in memory accounting — slab in /proc/meminfo, separate from application memory and separate from the page cache this chapter already covered extensively. On a host running an unusually large number of processes, network connections, or open files, slab memory (dentry/inode caches especially) can grow into a genuinely significant fraction of total memory usage, and — unlike the page cache — isn't always reclaimed as eagerly or as predictably under pressure, depending on the specific cache and kernel version.
/proc/meminfo field | What it represents |
|---|---|
Slab | Total kernel slab-cache memory, reclaimable and unreclaimable combined |
SReclaimable | The reclaimable portion — mostly dentry/inode caches, given back under real pressure |
SUnreclaim | The unreclaimable portion — actively in-use kernel structures, not given back until genuinely freed |
# See slab memory usage broken down by individual cache type
sudo slabtop --once
# OBJS ACTIVE USE OBJ SIZE SLABS OBJ/SLAB CACHE SIZE NAME
# 84212 81344 96% 0.10K 2108 40 8432K dentry
# 41200 39100 94% 0.19K 980 42 7840K inode_cacheNote
This is genuinely more of an "know it exists, and where to look" fact than a routine tuning target for most platform engineers — but on a host with unexplained memory usage that doesn't map cleanly to any application process or the page cache, checking slabtop//proc/meminfo's Slab field is exactly the right next step before concluding the discrepancy is unexplainable.
Page Tables — How Virtual Addresses Become Physical Ones#
Every virtual address a process uses has to be translated into a physical RAM address before the CPU can actually read or write it — this translation is the job of page tables, a per-process, hierarchical data structure the CPU's memory management unit (MMU) walks on every single memory access.
What to notice: on x86-64, a full page-table walk for one virtual address requires traversing five levels (PGD → P4D → PUD → PMD → PTE), and each level is itself a memory access — meaning a single "logical" memory access can cost up to five real RAM round-trips if none of the intermediate tables are cached anywhere. This is precisely the cost the next section's mechanism exists to eliminate for the overwhelming majority of real accesses.
From the Trenches: a team profiling inventory-service's in-memory index structure found that randomly scattering many small allocations across a huge virtual address range produced measurably worse performance than an otherwise-identical workload using a smaller, more tightly packed address range — despite both using the same total physical memory. The immediate cause, confirmed with perf stat's page-walk-related hardware counters, was that the scattered allocation pattern touched a much larger number of distinct page-table entries, increasing the average cost of address translation across the workload's actual memory accesses. The underlying, two-levels-deep condition was that the team's mental model of "memory is memory" ignored that the address-space layout of an allocation pattern has its own real translation cost, independent of the total bytes involved — a cost invisible to a coarse "how much RAM is this process using" metric. The fix was switching to an allocator strategy that packed related data more tightly in the virtual address space, directly reducing the number of distinct page-table entries the workload's hot path touched.
The TLB — Why Virtual Memory Isn't Free#
A five-level page-table walk on every single memory access would be prohibitively slow — Part 1 established that even one extra RAM round-trip costs roughly 200 cycles, and a full walk can cost several. The TLB (translation lookaside buffer) is a small, extremely fast, per-core cache of recently used virtual-to-physical address translations, checked before the CPU ever resorts to a full page-table walk.
What to notice: the TLB is small by necessity (typically covering a few hundred to a couple thousand entries, far less than an application's full working set on any real service), which means a workload's TLB hit rate — how often a translation is already cached versus requiring a full walk — is a genuinely significant, mostly invisible performance factor, directly analogous to Part 1's cache-hit-rate discussion but for address translation specifically rather than data. Published research on address-translation overhead has measured real application performance reduced by as much as 30% purely from TLB-miss costs on translation-heavy workloads — a number worth internalizing as "this is not a rounding-error concern."
Part 2's context-switching coverage connects directly here: switching between processes can flush or partially invalidate the TLB (since a new process's virtual addresses map to entirely different physical frames), which is exactly why the "cold cache" penalty Part 2 described after a context switch includes cold TLB state, not just cold data/instruction caches — an under-appreciated compounding cost of excessive context switching.
Tip
Best practice: for a memory-access-heavy service where profiling shows meaningful time in address translation (visible via perf stat's TLB-miss counters), the next section's huge pages are the standard, well-understood mitigation — reducing the number of distinct translations a given amount of memory requires in the first place.
Interview-ready line: "The TLB caches recent virtual-to-physical address translations so the CPU doesn't have to walk the full page-table hierarchy on every memory access. Its capacity is small relative to a real application's working set, so translation-heavy workloads pay a genuine, measurable cost on TLB misses — huge pages address this directly by making each cached translation cover far more memory, reducing how many distinct translations a given working set needs in the first place."
Huge Pages — Trading Flexibility for Fewer TLB Misses#
If the TLB can only cache a limited number of translations, and each translation covers exactly one 4 KB page, then a workload with a large working set inevitably experiences more TLB misses than the TLB's capacity can absorb. Huge pages — 2 MB or 1 GB pages instead of the standard 4 KB — directly address this: one huge-page translation covers 512x (2 MB) or 262144x (1 GB) as much memory as a standard translation, dramatically reducing how many distinct entries a given working set needs.
| Page size | Coverage per TLB entry | Page-table walk depth | Trade-off |
|---|---|---|---|
| 4 KB (standard) | 4 KB | Full 5 levels (x86-64) | Maximum flexibility, most TLB pressure for large working sets |
| 2 MB (huge page) | 512x more than standard | Stops one level earlier (4 levels) — both fewer entries needed and a shorter walk on a miss | Less flexible allocation granularity, real reduction in TLB pressure |
| 1 GB (huge page) | 262144x more than standard | Stops two levels earlier | Extreme reduction in TLB pressure, coarsest allocation granularity — typically reserved for very large, dedicated allocations (database buffer pools, some in-memory caches) |
Linux offers two distinct ways to use huge pages, worth distinguishing clearly:
- Transparent Huge Pages (THP) — the kernel automatically, transparently backs eligible anonymous memory regions with huge pages when it judges it beneficial, with no application changes required. Modern kernels (6.8+) support multi-size THP (mTHP), allocating intermediate sizes (16 KB through 1 MB, in power-of-two steps) rather than only the full 2 MB size, reducing the "wasted" memory of rounding a small allocation up to a full huge page.
- Explicit HugeTLB pages — an application deliberately reserves a pool of huge pages at boot/configuration time and maps them explicitly, a more predictable but less flexible mechanism traditionally favored by databases and specialized high-performance workloads that want full, deliberate control rather than the kernel's automatic heuristics.
From the Trenches: a database team running inventory-service's primary datastore on a host with THP enabled by default noticed periodic, unpredictable latency spikes correlating with memory pressure, distinct from any query-pattern change. The immediate cause was THP's defrag behavior — under certain kernel versions and configurations, the kernel would synchronously attempt to compact memory into huge-page-sized contiguous blocks on the allocation path itself, a real, sometimes lengthy pause directly in the critical path of a memory allocation. The underlying, two-levels-deep condition was that THP's automatic, "beneficial by default" design assumption didn't account for this specific database's allocation pattern (frequent, latency-sensitive allocations under an already memory-pressured host), where synchronous compaction cost more in tail latency than the TLB-pressure reduction saved. The fix was setting THP's defrag mode to a less aggressive setting (avoiding synchronous compaction specifically, while keeping THP's benefits for the steady-state working set) — a well-documented, specific tuning knob for exactly this trade-off, not a full THP disable.
Warning
THP defaults and behavior have genuinely changed across kernel versions and distributions — never assume a specific THP configuration without checking cat /sys/kernel/mm/transparent_hugepage/enabled on the actual host in question. A memory-latency investigation on an unfamiliar host should include this check early, not as an afterthought.
# THP mode: "always" (system-wide automatic), "madvise" (opt-in per allocation
# via madvise(MADV_HUGEPAGE)), or "never"
cat /sys/kernel/mm/transparent_hugepage/enabled
# THP's defrag behavior specifically — the setting this section's From the
# Trenches example tuned, independent of whether THP itself is enabled
cat /sys/kernel/mm/transparent_hugepage/defrag
# always defer defer+madvise [madvise] never
# ^^^^^^^^ this mode avoids synchronous
# compaction in the allocation path
# Explicit HugeTLB pool size, for applications requesting dedicated huge pages
cat /proc/sys/vm/nr_hugepages
cat /proc/meminfo | grep -i hugeThe Page Cache — Why "Free" Memory Isn't What It Looks Like#
Every time Linux reads a file from disk, it keeps a copy of that data in RAM — the page cache — so a subsequent read of the same data can be served from fast memory instead of a slow disk round-trip (Part 4 covers storage latency in full). This is the single most common source of confusion in free -h output: memory the page cache is using looks "used" at a glance, but it's actually the single most reclaimable, lowest-priority category of memory on the entire system.
What to notice: the page cache is deliberately designed to use essentially all "otherwise idle" memory — an idle host with plenty of free RAM and a lot of page cache usage is not wasting memory, it's using memory exactly as intended, ready to be instantly reclaimed the moment something else actually needs it. This is precisely why free -h's available column (not free, and not a naive subtraction from used) is the number that actually answers "how much memory can a new allocation actually get" — it already accounts for reclaimable cache.
free -h column | What it actually represents |
|---|---|
total | Total physical RAM |
used | Memory NOT readily reclaimable — application memory, kernel structures |
free | Genuinely untouched, unused memory — usually a small, uninteresting number on a healthy, long-running host |
buff/cache | Page cache plus kernel buffers — reclaimable, not a sign of a problem |
available | The real answer to "how much can a new allocation get" — free plus the reclaimable portion of buff/cache |
Important
This is the single most common false-alarm pattern in memory monitoring: a dashboard alerting on low free memory (or, worse, on used climbing toward total) rather than on available will trigger constantly on perfectly healthy, long-running hosts, since a healthy host's page cache naturally grows to fill available memory over time. Alert on available (or its Kubernetes/cgroup equivalent, covered later in this chapter), never on raw free or used in isolation.
From the Trenches: a newly onboarded on-call engineer, unfamiliar with this exact distinction, paged the team at 3 AM over a checkout-service host showing used: 58Gi out of 64 GB total in free -h, convinced the fleet was about to run out of memory. The immediate cause of the false alarm was reading used as "how much memory is actually needed by running work," when the overwhelming majority of that 58Gi was catalog-service's own request-log files, read repeatedly throughout the day and sitting comfortably in page cache — genuinely reclaimable, not a sign of any pressure at all. The underlying, two-levels-deep condition was that the engineer's mental model of free -h had never been corrected against this exact chapter's available-column distinction, because the host had simply never been memory-pressured enough during their tenure to force anyone to explain the difference — the confusion was dormant until the first time someone actually looked closely at a used number that happened to look alarming. The fix, beyond resolving the immediate false alarm, was adding this exact free/used/available distinction to the team's on-call onboarding materials, closing the same knowledge gap for the next new engineer before their own 3 AM page.
Dirty Pages and Writeback#
A page cache entry can be dirty — modified in memory but not yet written back to its underlying disk location — a distinction that matters because a dirty page is not freely reclaimable the way a clean (unmodified, read-only cached) page is; reclaiming it would lose data that hasn't been persisted yet.
Linux's writeback behavior is governed by two tunable thresholds, expressed as a percentage of available memory: vm.dirty_ratio (the point at which a process performing a write is itself forced to synchronously write dirty pages back to disk before continuing — a hard, application-visible stall) and vm.dirty_background_ratio (the lower threshold at which the kernel's background writeback daemon starts proactively flushing dirty pages, before hitting the hard synchronous-stall threshold).
| Threshold | What crossing it does |
|---|---|
vm.dirty_background_ratio (lower) | Background writeback daemon starts flushing proactively — invisible to application performance, mirroring kswapd's asynchronous role |
vm.dirty_ratio (higher) | The writing process itself is forced into synchronous writeback — a hard, application-visible stall, mirroring direct reclaim's synchronous role |
From the Trenches: catalog-service's batch image-processing job, which wrote large volumes of processed images to local disk in quick succession, periodically showed multi-second write-call stalls that didn't correlate with any disk-hardware health signal. The immediate cause, found by comparing the dirty-page ratio at the time of each stall against vm.dirty_ratio, was that the job's write rate was consistently outpacing the background writeback daemon's ability to flush dirty pages proactively, eventually crossing the hard dirty_ratio threshold and forcing the writing process itself into a synchronous, blocking writeback. The underlying, two-levels-deep condition was that the host's dirty-ratio thresholds were left at generic distribution defaults tuned for typical mixed workloads, never revisited for this specific host's actual role as a dedicated high-throughput batch-writer — a workload shape the defaults were never designed around. The fix was lowering vm.dirty_background_ratio specifically (triggering earlier, more gradual background flushing) so the background daemon could keep pace with the write rate, keeping the workload below the hard synchronous-stall threshold in practice.
Tip
Best practice: for any write-heavy batch workload showing unexplained write-call latency spikes, check /proc/vmstat's nr_dirty (currently dirty pages) against the host's vm.dirty_ratio/vm.dirty_background_ratio settings before assuming the disk hardware itself is the bottleneck — this is a distinct, tunable software threshold, not a hardware limit.
Memory Fragmentation and Compaction#
A host can genuinely have plenty of total free memory and still fail to satisfy a specific allocation — the huge-page section's THP defrag behavior is one direct symptom of a more general problem: external fragmentation, where free memory exists but isn't contiguous enough to satisfy a request that needs a large contiguous block.
What to notice: the total free memory across the three small fragments on the left could easily exceed the 2 MB block needed on the right, yet none of those fragments individually is large enough — this is exactly why huge-page allocation can fail (or trigger THP's compaction behavior) even on a host with abundant total free memory. Compaction is the kernel's fix: relocating in-use pages to consolidate free memory into larger contiguous regions, either proactively in the background or, as the earlier THP section described, synchronously in an allocation's own critical path when a large contiguous block is needed immediately.
| Fragmentation signal | Where to check it |
|---|---|
| Overall fragmentation state per memory zone | /proc/buddyinfo — shows the free-block count at each size order; many small-order blocks and few large-order ones indicates real fragmentation |
| Whether compaction is actively running | /proc/vmstat's compact_stall (synchronous, blocking compaction) versus compact_success/compact_fail (background attempts) |
A long-running host that has never been rebooted, with a highly varied mix of allocation sizes over its lifetime, is more prone to accumulating fragmentation than a freshly booted one — a real, if second-order, argument for periodic planned restarts of long-lived hosts running huge-page-dependent workloads, beyond the more commonly cited reasons (patching, general hygiene).
# Free-block distribution per zone/order — many entries at low orders
# (small blocks) and few at high orders (large contiguous blocks) is
# the direct signature of real fragmentation
cat /proc/buddyinfo
# Node 0, zone Normal 412 203 88 19 4 1 0 0 0 0 0
# ^^^ order 0 (4KB) order 9 (2MB) and up: nearly emptyThe Kernel's Reclaim Path — kswapd and Direct Reclaim#
The page cache and swap sections both describe what gets reclaimed — this section covers how reclaim actually happens, because the mechanism has a real, distinct performance consequence worth knowing by name.
What to notice: kswapd's background reclaim is designed to be invisible to application performance — it runs asynchronously, in its own kernel thread, reclaiming memory proactively before pressure becomes severe. Direct reclaim is the fallback when kswapd can't keep pace: the very process trying to allocate memory is forced to do reclaim work itself, synchronously, directly inside its own allocation call — a real, sometimes substantial latency stall directly in that process's critical path, distinct in kind from kswapd's invisible background work.
| Signal | What it means |
|---|---|
kswapd CPU usage rising, application latency unaffected | Healthy — the background mechanism working as designed |
allocstall counters in /proc/vmstat climbing | Direct reclaim is happening — some process's allocation calls are stalling synchronously |
| Application-level latency spikes correlating with memory pressure, with no corresponding I/O or CPU signal | A strong candidate for direct-reclaim stalls, worth checking allocstall directly before looking elsewhere |
Tip
Best practice: grep allocstall /proc/vmstat, tracked over time, is a specific, underused signal for exactly this class of "unexplained latency under memory pressure" investigation — a nonzero, climbing allocstall count is direct evidence that reclaim itself, not just what's being reclaimed, is contributing to application-visible latency.
Interview-ready line: "The kernel has two reclaim paths: kswapd runs proactively in the background, reclaiming memory before pressure becomes severe, invisibly to application performance. Direct reclaim is the fallback — when kswapd can't keep pace, the process trying to allocate memory is forced to reclaim synchronously inside its own allocation call, a real latency stall in that process's critical path. allocstall in /proc/vmstat is the direct signal that direct reclaim, not just background kswapd activity, is happening."
Swap and vm.swappiness — Tuning Reclaim Behavior#
When the kernel needs to reclaim memory and clean page cache alone isn't enough, it can fall back to swapping — writing pages belonging to an actual running process out to disk-backed swap space, freeing the physical RAM they occupied. This site's Linux & Networking Fundamentals series already covers why heavy swap activity is a real warning sign, not benign self-healing; this section covers the tunable that controls how eagerly the kernel reaches for it in the first place.
vm.swappiness is a kernel parameter from 0 to 200 (100 was the traditional maximum before recent kernels extended the range) controlling the kernel's relative preference for reclaiming page cache versus swapping out application memory when both are viable reclaim targets.
vm.swappiness value | Behavior |
|---|---|
| 0 | Swap only as an absolute last resort — strongly prefer reclaiming page cache first |
| 60 (typical distribution default) | A balanced default, reaching for swap moderately under memory pressure |
| Higher values | More aggressive swapping, reclaiming page cache less readily |
Why a database or latency-sensitive service commonly sets this low (0-10): for a workload where its own in-memory working set (query cache, connection state) is more valuable to keep resident than filesystem page cache, a low swappiness value tells the kernel to exhaust reclaimable cache before ever touching the application's own memory — directly avoiding the severe, confusing latency degradation swap activity causes, at the cost of potentially evicting page cache more aggressively than a generalist workload would want.
Note
vm.swappiness only matters at all on a host with swap space configured — a host with no swap (common for some containerized/cloud-native deployments, where swap is deliberately disabled) has nothing for this setting to influence; the kernel falls straight to page-cache reclaim and, if that's insufficient, the OOM killer this site's Linux & Networking Fundamentals series already covers.
# Current swappiness value
cat /proc/sys/vm/swappiness
# Confirm whether swap is even configured on this host at all — check
# BEFORE assuming swappiness has any effect
swapon --show
free -h | grep -i swap
# Live swap activity — si/so columns, the exact signal this site's
# Linux & Networking Fundamentals series flags as a real warning sign
vmstat 1Memory Overcommit — Why malloc() Can Lie#
Linux, by default, allows processes to allocate (via malloc() or mmap()) more virtual memory than the system actually has physical RAM (plus swap) to back — a deliberate design choice called memory overcommit, based on the observation that most allocated memory is never actually fully touched by the requesting process.
What to notice: a successful malloc() call is not a guarantee the memory is actually available — it's a reservation of virtual address space, with physical RAM only actually committed lazily, page by page, the first time each page is genuinely written to. This is precisely why an application can appear to "successfully allocate" far more memory than a host physically has, only to be OOM-killed later when it actually starts using what it thinks it already has — the failure surfaces at the wrong time relative to the request that actually caused it, which is a genuinely common source of confusing debugging sessions.
/proc/sys/vm/overcommit_memory controls this behavior directly: 0 (the default heuristic mode) allows reasonable-looking overcommits while rejecting obviously absurd ones, 1 allows essentially unlimited overcommit, and 2 disables overcommit entirely, requiring every allocation to be backed by real, available memory+swap up front — a stricter, more predictable mode some memory-critical services deliberately choose specifically to make allocation failures happen at malloc() time (where application code can handle them) rather than later, as a surprise OOM kill.
overcommit_memory value | Behavior | Trade-off |
|---|---|---|
0 (default) | Heuristic — allows reasonable overcommits, rejects obviously absurd ones | Balanced default for general-purpose workloads |
1 | Essentially unlimited overcommit | Maximum flexibility, maximum risk of a later surprise OOM kill |
2 | Overcommit disabled — every allocation must be backed by real available memory+swap | Allocation failures happen predictably at malloc() time, at the cost of some legitimate "reserve more than you'll use" patterns failing outright |
mmap() and Memory-Mapped Files — Blurring the Line Between Memory and Storage#
Everything so far has treated memory and files as separate worlds, connected only by the page cache's read-through/write-back behavior. mmap() collapses that separation directly: it maps a file's contents straight into a process's virtual address space, so ordinary memory reads and writes (no read()/write() syscalls at all) transparently become file I/O, going through exactly the same page cache and page-table machinery this chapter has already covered.
Why this matters practically: many databases and high-performance data stores use mmap() deliberately, specifically to let the kernel's already-tuned page cache and reclaim machinery manage their data files, rather than reimplementing equivalent caching logic in application code. It also means a memory-mapped file's pages participate in everything else this chapter has covered — they can be evicted under memory pressure (and re-faulted back in later, transparently), they show up in a process's RSS, and dirty mapped pages follow the same writeback rules as any other dirty page.
| Scenario | What mmap() gives you |
|---|---|
| Reading a large file once, sequentially | Marginal benefit over read() — the kernel's prefetching handles sequential reads well either way |
| Randomly accessing a large file's contents repeatedly | A real win — the kernel's page cache keeps hot regions resident automatically, with no application-level cache management code needed |
| Sharing data between multiple processes | mmap() with MAP_SHARED lets multiple processes map the same physical pages, a genuinely efficient IPC mechanism distinct from copying data between processes |
| A file larger than available RAM | Still works — the kernel pages in only the portions actually accessed, backed by the same demand-paging mechanism as regular virtual memory |
From the Trenches: a team building catalog-service's local search-index reader chose mmap() specifically to avoid holding a multi-gigabyte index fully in application memory, expecting the kernel's page cache to keep the actively-queried portions resident automatically. Under sustained memory pressure from a co-located batch job, query latency for the search index degraded noticeably, tracing back to page faults on index reads that had previously been served from cache. The immediate cause was straightforward given this chapter's reclaim coverage: the mapped index's pages were exactly the kind of reclaimable, clean page-cache memory the kernel evicts first under pressure (this chapter's page-cache section), so the batch job's own memory demand was directly evicting the search index's hot working set. The underlying, two-levels-deep condition was that mmap()'s "let the kernel manage it" benefit assumed the kernel would only ever be balancing this workload's needs, when in practice it was co-located with a genuinely competing memory consumer — the same reclaim mechanism that makes mmap() convenient in isolation makes it vulnerable to exactly this kind of cross-workload contention. The fix was separating the batch job onto different infrastructure, removing the memory-pressure source rather than trying to tune around it.
Copy-on-Write and fork()#
When a process calls fork() to create a child process, Linux does not immediately duplicate the parent's entire memory — it uses copy-on-write (COW): both parent and child initially share the exact same physical pages, marked read-only, and a genuine physical copy of a specific page is only made the moment either process actually attempts to write to it.
Why this matters operationally, beyond being a clever kernel trick: fork()-based process models (a common pattern for pre-forking web servers, some deployment/build tooling) are cheap specifically because of COW — forking a process with a large memory footprint doesn't actually duplicate that footprint unless the child genuinely modifies a large fraction of it. A workload that forks frequently and then writes heavily to most of its memory defeats much of COW's benefit, effectively paying the full copy cost anyway, just deferred to individual page faults instead of one upfront fork() cost — worth knowing when reasoning about why a fork-heavy workload's memory behavior doesn't match the "forking is cheap" intuition.
| Scenario | COW benefit |
|---|---|
| A pre-forking web server, workers rarely writing to most of the parent's mapped memory | Large — most memory genuinely stays shared for the worker's whole lifetime |
| A build tool forking a short-lived helper process that mostly reads shared state | Large — the COW model's ideal case |
| A worker that immediately writes across most of its inherited memory after forking | Small — most pages get copied almost immediately anyway, just paid as scattered page faults instead of one upfront cost |
RSS vs. VSZ vs. PSS — Reading Memory Usage Correctly#
ps/top report several different memory numbers per process, and conflating them is one of the most common sources of genuinely wrong capacity-planning conclusions in real operational practice.
| Metric | What it measures | The trap |
|---|---|---|
| VSZ (virtual size) | Total virtual address space reserved, including memory-overcommit's unbacked reservations and memory-mapped files | Almost always a huge, largely meaningless number for capacity purposes — reflects reservation, not real usage |
| RSS (resident set size) | Physical RAM currently actually mapped to this process, INCLUDING memory shared with other processes (shared libraries, COW pages) | Double-counts shared memory when summed across processes — adding up every process's RSS overstates total real usage |
| PSS (proportional set size) | Like RSS, but shared pages are divided proportionally across the processes sharing them | The metric that actually sums correctly across a whole host — the right one for "how much real RAM is this fleet of processes using in total" |
From the Trenches: a capacity-planning exercise summed every checkout-service worker process's RSS to estimate the host's total memory footprint, and the number came out significantly higher than free -h's actual used figure for the entire host — a discrepancy that initially looked like a monitoring bug. The immediate cause was that the workers, forked from a common parent (this section's copy-on-write mechanism), shared a large fraction of their memory (loaded libraries, shared read-only data) — RSS counted that shared memory fully for every single worker, while the host obviously only holds one physical copy of it. The underlying, two-levels-deep condition was that RSS was never designed to be summed across related processes in the first place — it answers "how much memory does this one process touch," not "how much unique memory does this group of processes collectively require," and the team's spreadsheet-style summation implicitly assumed the latter. The fix was switching the capacity model to PSS specifically, which sums correctly by design, immediately resolving the discrepancy.
Tip
Best practice: never sum RSS across multiple related processes (forked workers, container siblings sharing a base image's mapped libraries) to estimate total memory usage — use PSS (/proc/<pid>/smaps_rollup's Pss field, or smem's reporting) for any calculation that needs to add up correctly across more than one process.
NUMA Memory Locality, Revisited#
Part 1 introduced NUMA from the CPU-topology angle — this section makes the memory-allocation side of that story explicit, since it's this chapter's mechanisms (page allocation, the page cache, huge pages) that actually determine which NUMA node a given page physically lands on.
Linux's default page-allocation policy is local-first: a process's memory allocations default to landing on the same NUMA node as the core that requested them, specifically to minimize the cross-node latency penalty Part 1 covered. This default can break down exactly the way Part 1's own From the Trenches example described — a process migrated to a different node's cores after its memory was already allocated, or memory footprint large enough to spill across nodes regardless of policy.
Allocation policy (via numactl --interleave, --membind, or the kernel default) | Behavior |
|---|---|
| Default (local-first) | Allocate on the requesting core's own node when possible |
--membind=N | Force allocation onto a specific node only — fails or reclaims aggressively if that node fills up, rather than silently spilling elsewhere |
--interleave=all | Deliberately spread allocations round-robin across all nodes — trades worse average latency for more predictable, evenly-distributed bandwidth usage, useful for some large, genuinely node-spanning workloads |
Huge pages interact with NUMA locality in a worth-knowing way: because a 1 GB huge page is a large, indivisible unit, it must be allocated entirely from one NUMA node — a workload with a huge-page-backed working set that's slightly too large for one node's local memory can be forced to spill an entire additional huge page onto a remote node, a much larger locality "miss" in one step than standard 4 KB pages would produce.
# Confirm which NUMA node a process's memory actually landed on
numastat -p <pid>
# Node 0 Node 1 Total
# --------------- --------------- --------------- ---------------
# Huge 0.00 0.00 0.00
# Heap 1842.30 12.40 1854.70 <-- overwhelmingly node 0, good locality
# Stack 0.05 0.00 0.05Memory Bandwidth — The Resource Nobody Names#
Every resource covered so far in this series has an obvious name and an obvious metric — CPU has utilization, memory has capacity in gigabytes. Memory bandwidth — how much data can actually move between RAM and the CPU per second — is a real, frequently overlooked resource with its own ceiling, shared across every core on a socket, that neither CPU utilization nor memory-capacity metrics reveal at all.
What to notice: this diagram looks structurally similar to Part 1's shared-L3-cache diagram, and the implication is the same kind — a resource every core draws from that has a real, finite total capacity, invisible to any single core's own utilization metric. A memory-bandwidth-bound workload (one that moves large volumes of data through memory relative to how much actual computation it does per byte — data-parallel numeric workloads, some AI/ML inference and training workloads, large in-memory data scans) can show moderate CPU utilization on every core while still being completely bottlenecked, because the cores are spending their time waiting on memory transfers the shared bus can't deliver fast enough, not on computation.
| Symptom | Likely explanation |
|---|---|
| Adding more cores to a memory-bandwidth-bound workload yields little to no additional throughput | The bottleneck is the shared memory bus, not compute — more cores just means more contenders for the same finite bandwidth (a close cousin of Part 1's Amdahl's Law discussion, with the "serial fraction" replaced by a genuinely shared hardware resource) |
CPU utilization moderate on every core, but per-core IPC (instructions per cycle, visible via perf stat) unusually low | Cores are spending cycles stalled waiting on memory, not executing — a classic memory-bandwidth-bound signature |
| A NUMA-aware workload (Part 1, and this chapter's own NUMA section) still underperforms even with perfect local-node allocation | Even local-node bandwidth has a ceiling — NUMA locality reduces latency, but doesn't create additional bandwidth beyond what that node's memory controller can deliver |
Note
This is genuinely more relevant to specialized numeric/AI-ML workloads than typical web-service request handling, which is why it's covered briefly here rather than as a full chapter section — but recognizing the symptom (moderate CPU utilization, low IPC, more cores not helping) is worth having even for a generalist platform engineer, specifically to avoid mis-diagnosing a bandwidth-bound workload as simply needing more cores when it structurally cannot benefit from them.
OOM Score and oom_score_adj — Influencing Victim Selection#
This site's Linux & Networking Fundamentals series already covers that the OOM killer exists and roughly what triggers it — this section covers how it picks a victim when multiple processes are candidates, which is directly, practically tunable.
Every process has an oom_score (a computed, read-only "badness" value the kernel maintains) and an oom_score_adj (a manually settable adjustment from -1000 to +1000) visible under /proc/<pid>/. The kernel's OOM killer selects the process with the highest effective score as its victim — factoring in memory usage (RSS, including child processes), and the manual adjustment on top.
oom_score_adj value | Effect |
|---|---|
-1000 | Process is effectively exempt from the OOM killer entirely |
0 (default) | No manual adjustment — pure computed badness decides |
+1000 | Process is guaranteed to be selected first if any process is killed |
Kubernetes sets this automatically per pod, directly from QoS class — a direct, practical application of a kernel mechanism most platform engineers never touch by hand: Guaranteed pods get a strongly negative adjustment (around -997, near-immune), BestEffort pods get +1000 (first to be killed), and Burstable pods land in between, scaled roughly by how much of their memory request their actual usage represents. This is precisely the mechanism connecting Part 2's QoS-class coverage to real OOM behavior on a memory-pressured, multi-tenant node — the same QoS classification that determined CPU scheduling priority in Part 2 also determines OOM-kill priority here.
Important
This system-wide oom_score_adj mechanism operates at the node level, selecting a victim across every process on the host — distinct from the cgroup-scoped OOM killer covered next, which only ever selects among processes within one specific cgroup once that cgroup's own memory.max is crossed. A node under genuine system-wide memory pressure (not caused by any single container exceeding its own limit) invokes this node-level mechanism, and QoS class is exactly what determines which pod's containers are sacrificed first.
# Check a specific process's current badness score and adjustment directly
cat /proc/<pid>/oom_score
cat /proc/<pid>/oom_score_adj
# Confirm which QoS class Kubernetes assigned a pod, which drove the
# oom_score_adj value above
kubectl get pod <pod-name> -o jsonpath='{.status.qosClass}'cgroup Memory Limits and the Cgroup OOM Killer#
Every Kubernetes memory request and limit, like Part 2's CPU equivalents, is implemented through cgroups — and memory's cgroup mechanism is meaningfully different from CPU's, because memory can't simply be "throttled" the way CPU time can; it has to be reclaimed, or, in the worst case, a process has to be killed.
What to notice, and why it's a genuinely different mechanism from CPU quotas: memory.high (soft limit, triggers aggressive reclaim and throttling, an attempt to avoid killing anything) and memory.max (hard limit, triggers the cgroup-scoped OOM killer once crossed) are two distinct cgroup v2 controls working together — Kubernetes derives memory.max directly from a pod's memory limit. Critically, the OOM killer invoked here is scoped to the cgroup — it selects a victim process from within that specific container's cgroup only, never reaching outside it to kill an unrelated process elsewhere on the host, which is exactly the isolation guarantee that makes per-pod memory limits meaningful at all.
A specific, current Kubernetes behavior worth knowing: modern Kubernetes versions set memory.oom.group to true for container cgroups, meaning an OOM event kills every process in the container's cgroup together, not just a single highest-usage process — removing the ambiguity of a multi-process container surviving in a partially-killed, inconsistent state.
| Kubernetes memory setting | cgroup v2 control | What crossing it does |
|---|---|---|
resources.requests.memory | Informational for scheduling (kube-scheduler's node-fit calculation) — no direct cgroup enforcement mechanism of its own | Used to decide which node a pod fits on; not directly enforced by the kernel the way CPU shares are |
resources.limits.memory | memory.max | Cgroup-scoped OOM kill of the whole container's process group |
A note on cgroup v1, following Part 2's own cgroup v1/v2 comparison pattern: memory's v1 equivalent of memory.max is memory.limit_in_bytes, and v1 has no direct equivalent of v2's memory.high soft-throttle mechanism at all — v1's memory controller is comparatively cruder, another real reason current Kubernetes clusters default to cgroup v2 where available.
Warning
Unlike CPU throttling (Part 2), which merely slows a workload down, crossing a memory limit results in a process being killed outright, visible in Kubernetes as OOMKilled in kubectl describe pod. There is no graceful degradation equivalent to CPU throttling for memory — this asymmetry is exactly why memory limits deserve more conservative headroom than CPU limits in most real sizing decisions.
From the Trenches: catalog-service's image-processing pods showed periodic OOMKilled events despite average memory usage sitting comfortably under the configured limit on every dashboard the team checked. The immediate cause was a genuine short-lived spike — a specific image-processing operation briefly allocated a large temporary buffer well above the pod's steady-state average, crossing memory.max for long enough to trigger the cgroup OOM killer before the spike itself would have subsided on its own. The underlying, two-levels-deep condition was structurally identical to Part 2's CPU-throttling lesson applied to memory: the team's capacity dashboard showed average and even peak-over-a-minute memory usage, but the OOM killer reacts to the instantaneous state at the moment of allocation, not a smoothed window — a brief-enough spike can be invisible to any dashboard granularity coarser than the spike itself. The fix combined raising the memory limit to give the known spike genuine headroom, and instrumenting the specific operation to reuse a pre-allocated buffer instead of allocating a fresh large one per invocation.
Reading Memory Behavior on a Real Machine#
Every mechanism in this chapter is directly observable on a real host — this quick-reference table maps each diagnostic to the section that explains what it means:
| Command | What it reveals | Chapter section |
|---|---|---|
free -h | The corrected used/free/available/cache breakdown | The Page Cache |
cat /proc/<pid>/status | grep VmRSS | This process's resident memory, uncorrected for sharing | RSS vs. VSZ vs. PSS |
cat /proc/<pid>/smaps_rollup | grep Pss | This process's proportionally-shared, correctly-summable memory | RSS vs. VSZ vs. PSS |
grep -i dirty /proc/vmstat | Currently dirty, not-yet-written-back page count | Dirty Pages and Writeback |
grep allocstall /proc/vmstat | Synchronous, allocation-blocking direct reclaim events | The Kernel's Reclaim Path |
cat /proc/buddyinfo | Free-memory fragmentation, by contiguous block size | Memory Fragmentation and Compaction |
cat /sys/kernel/mm/transparent_hugepage/enabled | Current THP mode | Huge Pages |
sudo slabtop --once | Kernel object-cache memory usage, by type | Slab Allocation |
ps -o min_flt,maj_flt -p <pid> | Minor vs. major page fault counts | Physical Memory |
cat /proc/<pid>/oom_score / oom_score_adj | This process's current OOM-kill priority | OOM Score and oom_score_adj |
cat .../memory.current, memory.max, memory.events | A cgroup's current usage, hard limit, and OOM-kill history | cgroup Memory Limits |
# The corrected free/used/available breakdown this chapter's page-cache section explains
free -h
# total used free shared buff/cache available
# Mem: 62Gi 18Gi 1.2Gi 412Mi 43Gi 42Gi
# ^^^^ ^^^^
# mostly page cache, reclaimable the real number
# Current dirty-page state, relevant to the writeback section
grep -i dirty /proc/vmstat
# nr_dirty 4821
# nr_writeback 112
# THP status — confirm before assuming a specific configuration
cat /sys/kernel/mm/transparent_hugepage/enabled
# always [madvise] never <-- current mode is "madvise" (opt-in per allocation)
# TLB-miss-aware profiling, extending Part 1's perf stat usage
perf stat -e dTLB-load-misses,dTLB-loads -- ./inventory-service-benchmark
# Per-process, correctly-summable memory usage
cat /proc/<pid>/smaps_rollup | grep Pss
# Pss: 184320 kB
# A container's cgroup v2 memory pressure signals directly
cat /sys/fs/cgroup/kubepods.slice/.../memory.current
cat /sys/fs/cgroup/kubepods.slice/.../memory.max
cat /sys/fs/cgroup/kubepods.slice/.../memory.events
# oom_kill 3 <-- this container has been OOM-killed 3 times
# Fragmentation state, and whether direct reclaim/compaction is stalling allocations
cat /proc/buddyinfo
grep -E "allocstall|compact_stall" /proc/vmstatA realistic worked triage, tying the chapter together: a catalog-service pod alert fires for OOMKilled, with a dashboard showing average memory usage comfortably under the configured limit. The investigation sequence a platform engineer familiar with this chapter would actually run, in order:
cat .../memory.eventson the pod's cgroup — confirm the OOM kill actually happened here, not somewhere else in the node's hierarchy, and get an exact count.- Check whether the dashboard's sampling interval could plausibly hide a short spike — this chapter's own memory-limit-sizing lesson says yes almost always, so this is treated as the leading hypothesis, not a last resort.
free -hand/proc/vmstat's dirty/reclaim counters on the node itself — rule out a node-wide memory-pressure event (competing with a noisy neighbor) as a contributing factor, distinct from this pod's own allocation behavior.- If the application can be instrumented, log peak allocation size around suspected spike windows — the most direct way to confirm the root cause rather than inferring it indirectly from kernel counters alone.
This ordering mirrors the same "cheapest and most likely signal first" logic used throughout this series — checking the cgroup's own OOM record costs one command and either confirms or reframes everything that follows.
How These Concepts Show Up on the Cloud Bill#
Following this series' established closing pattern — a deliberate synthesis, not a new topic.
| Chapter concept | Cost implication | The FinOps-relevant question to ask |
|---|---|---|
| Page cache misread as "used" memory | Over-provisioning memory to chase a scary-looking but healthy used metric wastes real spend | Is capacity planning driven by available/PSS, or by a metric that conflates reclaimable cache with genuine pressure? |
| Memory limits sized against average instead of real peak/spike behavior | Under-sizing causes recurring OOMKilled restarts (and the reliability cost that comes with them); over-sizing wastes reserved capacity | Has the limit been validated against the workload's genuine peak allocation behavior, not just its steady-state average? |
| Huge pages / THP misconfiguration | Either forgoing a real TLB-pressure performance win, or paying an unexpected latency cost from synchronous compaction | Has THP's mode been deliberately chosen for this specific workload's allocation pattern, not left at a generic distribution default? |
| RSS-based capacity estimates for a fleet of related processes | Double-counting shared memory leads to over-provisioning an entire fleet based on an inflated total | Are fleet-wide memory estimates built from PSS, which sums correctly, rather than summed RSS? |
| QoS-class-driven OOM priority (requests vs. limits) | An under-requested Burstable pod is a cheap-looking but fragile choice — first in line to be OOM-killed under any real node pressure | Does the workload's actual reliability requirement match the OOM-priority tier its request/limit configuration puts it in? |
| Memory-bandwidth-bound workloads sized like compute-bound ones | Paying for more cores/larger instances that structurally cannot improve throughput for a bandwidth-limited workload | Has the workload's actual bottleneck (compute vs. bandwidth) been profiled before choosing a bigger instance as the fix? |
Tip
Best practice: treat memory-limit sizing with the same "review, not one-time decision" discipline this series applied to CPU limits — a limit correct at launch drifts as allocation patterns, dependencies, and traffic shapes change, and the two failure directions (too tight: OOMKilled restarts; too loose: wasted reserved capacity) both carry a real, ongoing cost.
A symptom-to-cause quick lookup, tying every mechanism in this chapter to what it actually looks like in production:
| Observed symptom | Most likely cause | Where this chapter covers it |
|---|---|---|
free -h shows low free/high used, but no application-level slowness | Healthy page cache usage — a false alarm, not a real problem | The Page Cache |
| Write-heavy job shows periodic multi-second stalls | Crossing vm.dirty_ratio, forcing synchronous writeback | Dirty Pages and Writeback |
| Latency spikes correlate with memory pressure, no I/O or CPU signal | Direct reclaim stalling the allocating process itself | The Kernel's Reclaim Path |
| Severe, confusing latency degradation under moderate memory pressure | Swap activity — check vm.swappiness and si/so in vmstat | Swap and vm.swappiness |
malloc() succeeds, OOM kill happens later, seemingly unrelated to the allocation | Memory overcommit — physical commitment is lazy, on first write | Memory Overcommit |
| Large contiguous (huge-page) allocation fails despite ample free memory | External fragmentation — check /proc/buddyinfo | Memory Fragmentation and Compaction |
OOMKilled with no corresponding spike visible on any dashboard | A spike shorter than the dashboard's sampling interval | cgroup Memory Limits |
| Moderate CPU utilization, low IPC, throughput flat as cores increase | Memory-bandwidth-bound workload, not compute-bound | Memory Bandwidth |
Common Mistakes and Interview Traps#
| Mistake | Why It's Wrong | Fix |
|---|---|---|
Alerting on low free or high used memory directly | Both ignore reclaimable page cache, producing false alarms on perfectly healthy hosts | Alert on available (or the cgroup-equivalent memory-pressure signal), which already accounts for reclaimability |
Assuming a successful malloc() guarantees the memory is genuinely available | Overcommit means physical RAM is only actually committed when pages are first written, not at allocation time | Understand that OOM risk surfaces at the point of first write, not at the original allocation call |
| Summing RSS across multiple related processes to estimate total memory usage | RSS counts shared memory fully for every process sharing it, double- (or N-times-) counting | Use PSS for any calculation summing memory across more than one process |
Treating vm.swappiness as irrelevant without checking whether swap is even configured on the host | The setting has no effect at all on a host with no swap space | Confirm swap configuration first; on swap-disabled hosts, focus entirely on page-cache reclaim and OOM behavior instead |
| Assuming a memory limit is "safe" because average usage sits well under it | A cgroup memory limit is enforced against instantaneous usage — a brief spike can trigger an OOM kill invisible to any averaged dashboard metric | Validate limits against genuine peak/spike allocation behavior, not just average utilization |
| Enabling or disabling THP host-wide without checking the actual workload's allocation pattern | THP's synchronous compaction behavior can introduce real latency for some workloads while genuinely helping others | Check the specific workload's sensitivity (latency-critical vs. throughput-oriented) before choosing a THP mode |
| Assuming a memory-bandwidth-bound workload just needs more cores | More cores just means more contenders for the same finite shared memory bus, not more effective throughput | Check per-core IPC and utilization together — moderate utilization with low IPC points at bandwidth, not compute, as the ceiling |
| Assuming plenty of total free memory rules out an allocation failure | External fragmentation can leave free memory scattered into blocks too small for a large contiguous request (huge pages especially) | Check /proc/buddyinfo for the actual size distribution of free blocks, not just the total |
Relying on mmap()-backed data staying resident without accounting for co-located memory pressure | Mapped file pages are ordinary reclaimable page cache — a competing workload's demand can evict them just like any other cached data | Consider the full memory-pressure picture of the host/node, not just the mapping workload's own footprint, when relying on mmap() for implicit caching |
| Under-requesting a genuinely important workload's memory to "fit more pods per node" | A low request pushes the pod toward Burstable/BestEffort OOM-kill priority, making it the first sacrificed under any real node-level pressure | Set requests to reflect the workload's actual reliability requirement, not just a packing-density target |
| Dismissing unexplained host memory usage without checking slab caches | Dentry/inode and other kernel object caches can consume a genuinely significant, non-application, non-page-cache share of memory | Check slabtop//proc/meminfo's Slab field before concluding a memory discrepancy is unexplainable |
Worked Practice Problems#
Problem 1: free -h on a checkout-service host shows used: 40Gi, buff/cache: 20Gi, available: 55Gi on a 64 GB host. A teammate proposes scaling to a larger instance because "we're using 40GB out of 64GB, that's over 60%." Is this the right read of the numbers?
Answer: No — used at 40Gi doesn't include the reclaimable page cache, and available (55Gi out of 64Gi) is the number that actually answers "how much can a new allocation get right now," showing the host is nowhere near real memory pressure. The proposal conflates page-cache usage (healthy, expected, freely reclaimable) with genuine application memory pressure. The correct read: this host has substantial headroom, and scaling based on used alone would be a wasted cost driven by a metric that structurally can't distinguish "cache filled with useful, reclaimable data" from "genuinely low on memory."
Problem 2: A catalog-service pod is repeatedly OOMKilled, but its memory-usage dashboard (sampled every 60 seconds) never shows usage anywhere near the configured limit. What's the most likely explanation, and how would you confirm it?
Answer: The most likely explanation is a memory spike shorter than the dashboard's sampling interval — the cgroup OOM killer reacts to instantaneous usage crossing memory.max, not a 60-second average, so a spike lasting a few seconds (or less) can trigger an OOM kill while remaining completely invisible to a 60-second-granularity graph. Confirmation: check memory.events' oom_kill counter directly against the cgroup, and if available, instrument the application to log its own peak allocation size around the time of each kill — correlating the two confirms a genuine short-lived spike rather than a sustained, dashboard-visible leak.
Problem 3: A team disables THP entirely, host-wide, after one latency-sensitive service showed a THP-related stall, without checking whether any other workload on the same fleet might have benefited from it. What's the risk in this decision, and what would a more targeted fix look like?
Answer: The risk is losing THP's real TLB-pressure benefit for every other workload on the fleet, potentially including workloads with large, address-translation-heavy working sets that genuinely benefited from huge pages — a host-wide disable optimizes for the one problem observed while silently regressing anything that wasn't. A more targeted fix, following this chapter's THP section, is tuning THP's defrag mode specifically (avoiding synchronous compaction on the allocation path, which was the actual source of the observed stall) rather than disabling the feature outright, or scoping the disable to just the specific latency-sensitive workload (via madvise mode and selectively opting workloads in) rather than applying it fleet-wide.
Problem 4: inventory-service's primary database, running on a dedicated host with vm.swappiness left at the distribution default of 60, shows periodic severe latency spikes correlated with si/so (swap in/out) activity in vmstat, even though the host has 3x the memory the database's working set actually needs on average. What's the most likely fix, and why does the "plenty of memory on average" fact not rule out swap as the cause?
Answer: The most likely fix is lowering vm.swappiness significantly (toward 0-10), telling the kernel to strongly prefer reclaiming page cache over swapping the database's own working set under pressure. The "plenty of memory on average" fact doesn't rule out swap because swappiness governs the kernel's reclaim-target preference under transient pressure, not a judgment about whether the host has enough memory overall — even a host with generous average headroom can experience brief pressure spikes (a backup process, a burst of connections, a large query's temporary memory use) during which the default swappiness setting may choose to swap out some of the database's resident working set rather than exhausting page cache first, producing exactly the kind of severe, confusing latency spike this site's Linux & Networking Fundamentals series already flags as a serious warning sign.
Problem 5: A team profiling a data-parallel numeric workload sees all 32 cores on a host at roughly 60% CPU utilization, but throughput doesn't improve at all when they double the core count on a larger instance. perf stat shows unusually low instructions-per-cycle (IPC) across every core. What resource is most likely the actual bottleneck, and why wouldn't simply adding cores fix it?
Answer: The symptom pattern — moderate utilization, low IPC, and throughput that doesn't scale with additional cores — is the signature this chapter attributes to a memory-bandwidth-bound workload, not a compute-bound one. Adding more cores doesn't help because the bottleneck is the shared memory bus/controller's finite total bandwidth, which every core draws from collectively — more cores simply means more contenders for the same fixed-size pipe, not more effective throughput, structurally similar to how Part 1's Amdahl's Law describes a serial bottleneck capping the benefit of more parallelism, except here the shared, capacity-limited resource is memory bandwidth itself rather than a piece of serial code.
Problem 6: A node under genuine memory pressure (not caused by any single pod exceeding its own limit) triggers a system-wide OOM kill. Two candidate pods are present: a Guaranteed-QoS checkout-service replica, and a BestEffort batch job with no requests or limits set at all. Which is killed, and why does this outcome hold regardless of which pod happens to be using more memory at that exact moment?
Answer: The BestEffort batch job is killed. Kubernetes sets oom_score_adj automatically from QoS class — Guaranteed pods receive a strongly negative adjustment (near -997, close to immune), while BestEffort pods receive +1000 (first in line). Because oom_score_adj is a direct, heavily-weighted input to the kernel's badness calculation, the QoS-class-driven adjustment dominates the outcome in the overwhelming majority of real scenarios regardless of the two pods' exact instantaneous memory usage — the mechanism is deliberately designed so QoS class, not a moment-to-moment memory snapshot, decides victim priority under genuine node-wide pressure.
Summary and What's Next#
Quick reference — key terms from this chapter:
| Term | One-line definition |
|---|---|
| Page table | The per-process, hierarchical structure translating virtual addresses to physical ones |
| TLB | A small, fast per-core cache of recent virtual-to-physical translations, avoiding a full page-table walk on every access |
| Huge page | A 2 MB or 1 GB page, trading allocation flexibility for dramatically reduced TLB pressure |
| Page cache | RAM holding recently-read file data, reclaimed first and most readily under memory pressure |
| Dirty page | A modified page cache entry not yet written back to disk — not freely reclaimable until flushed |
| vm.swappiness | The kernel's tunable preference for reclaiming page cache versus swapping application memory |
| Memory overcommit | Allowing virtual allocations to exceed physical RAM+swap, with physical pages committed lazily on first write |
| Copy-on-write | Sharing physical pages between a forked parent and child until either writes to one, triggering a real copy |
| RSS / PSS | Per-process resident memory (double-counts shared pages) versus proportionally-shared memory (sums correctly across processes) |
| memory.high / memory.max | The cgroup v2 soft-throttle and hard-OOM-kill thresholds underneath every Kubernetes memory request/limit |
| Slab allocator | The kernel's own pool-based allocator for small, fixed-size internal objects (task_struct, inodes, dentries) |
| External fragmentation | Free memory scattered into blocks too small to satisfy a large contiguous request, despite adequate total free memory |
| kswapd / direct reclaim | Background, asynchronous reclaim versus the synchronous, allocation-blocking fallback when background reclaim can't keep pace |
| mmap() | Mapping a file directly into a process's virtual address space, turning ordinary memory access into transparent file I/O |
| oom_score_adj | The per-process, node-scoped tunable (-1000 to +1000) influencing which process the system-wide OOM killer selects as its victim |
| Memory bandwidth | The finite, shared rate at which data can move between RAM and the CPU across every core on a socket |
This chapter's mechanisms compound in practice more than any single section suggests on its own — a workload can simultaneously be paying a TLB-miss tax, fighting for page-cache residency against a noisy neighbor, and sitting close to a memory-bandwidth ceiling, all contributing to the same observed latency, which is exactly why the diagnostic commands in this chapter are worth running together rather than checking one signal and stopping at the first plausible explanation.
Memory management determines whether a scheduled thread's next instruction actually executes quickly or stalls on a page fault, a TLB miss, or a reclaim operation — the layer Part 2's scheduler assumed away entirely. Part 4 moves to the next resource this series hasn't yet covered: storage. Everything this chapter described as "written back to disk" — dirty pages, swap, a page-cache miss triggering a real read — depends entirely on the storage layer's own performance characteristics, which Part 4 opens up in the same depth this chapter applied to memory: block devices, filesystems, RAID, I/O schedulers, and how to actually measure and reason about disk performance in production.