MADV_FREE and the RSS number that's lying to you
Published by RodHat

Your service is at 7 GB RSS. The OOM killer just fired. You ssh in, run free -h, and there’s 3 GB available. You kill the service and restart it. It comes up at 800 MB. You file a ticket titled “memory leak.”
The ticket is wrong. What you had was a monitoring problem.
MADV_FREE landed in Linux 4.5 (March 2016). jemalloc switched to it as the default decommit strategy around version 4.x. tcmalloc and mimalloc do the same. The effect: when your allocator calls madvise(ptr, len, MADV_FREE) on a region it’s returning to the OS, the kernel marks those pages as reclaimable and walks away. The pages stay in the process’s address space. They still count toward RSS. The allocator can reuse them without taking a page fault. If the kernel needs physical memory for something else, it reclaims them silently and zero-fills on next access. Until that happens, they’re phantom memory.
The old behavior, MADV_DONTNEED, is immediate: call it and the kernel zeros and returns those pages to the free pool right now. RSS drops. Next access faults them back in. Accurate but expensive when your allocator is cycling through malloc/free at any real rate, because every reuse is a page fault.
MADV_FREE is the right tradeoff for performance. It makes your RSS numbers unreliable.
What’s actually in smaps_rollup
The kernel exposes MADV_FREE’d pages in /proc/<pid>/smaps under the LazyFree field, added in Linux 4.12. smaps_rollup (Linux 4.14) aggregates all VMAs into a single summary so you don’t have to iterate thousands of lines:
grep -E '^(Rss|LazyFree):' /proc/$(pgrep -n myservice)/smaps_rollup
The output on a jemalloc-backed service under load might look like:
Rss: 7340032 kB
LazyFree: 6291456 kB
7 GB RSS. 6 GB of that is MADV_FREE pages that the kernel can reclaim without the process noticing. Real committed memory: roughly 1 GB. Your OOM killer fired on a number that was never real pressure.
The smaps and PSS accounting post covers the full field list in /proc/pid/smaps_rollup. LazyFree is the one field absent from most memory dashboards.
Which allocator does what
| Allocator | Default on Linux 4.5+ | Override to force MADV_DONTNEED |
|---|---|---|
| glibc malloc | MADV_DONTNEED (via brk/munmap) | it is the default |
| jemalloc >= 4.x | MADV_FREE | MALLOC_CONF="dirty_decay_ms:0,muzzy_decay_ms:0" |
| tcmalloc | MADV_FREE | TCMALLOC_RELEASE_RATE=0 |
| mimalloc | MADV_FREE | MIMALLOC_PURGE_DELAY=0 |
jemalloc has two decay categories. “Dirty” pages were recently written and freed by the allocator but not yet returned to the OS. “Muzzy” pages have already been MADV_FREE’d and are sitting in kernel limbo. Both decay timers default to 10,000 ms. Set both to 0 and freed pages go back immediately via MADV_DONTNEED, trading page fault overhead for accurate RSS.
MALLOC_CONF="dirty_decay_ms:0,muzzy_decay_ms:0" ./your-service
You can also adjust this at runtime via je_mallctl() if you link jemalloc directly, but the environment variable is enough to verify behavior without a code change.
What MemAvailable already knows
/proc/meminfo’s MemAvailable estimate treats MADV_FREE pages as recoverable memory. So free -h’s “available” column is telling you the truth. The per-process RSS is the lie.
This means your OOM kill may have been triggered correctly (real memory pressure on the system) but your post-mortem reading of which process was responsible and why is wrong. The kernel picks OOM victims using oom_score, which it calculates from RSS. A process sitting on 6 GB of MADV_FREE phantom RSS looks like a much more attractive target than it should be.
# What the OOM killer sees for your process
grep VmRSS /proc/$(pgrep -n myservice)/status
# The score and adjustment
cat /proc/$(pgrep -n myservice)/oom_score
cat /proc/$(pgrep -n myservice)/oom_score_adj
VmRSS in /proc/pid/status is the same inflated number. There’s no LazyFree-adjusted RSS field there. You need smaps_rollup for the real accounting.
Scripting the real number
#!/bin/sh
# Committed RSS minus MADV_FREE phantom pages
pid=$(pgrep -n "${1:?usage: $0 <process-name>}")
awk '
/^Rss:/ { rss = $2 }
/^LazyFree:/ { lazy = $2 }
END { printf "rss=%d kB lazy=%d kB committed=%d kB\n", rss, lazy, rss-lazy }
' /proc/$pid/smaps_rollup
smaps_rollup requires Linux 4.14. If you’re on anything older than that in production in 2026, the monitoring problem is the second item on your list.
Prometheus’s node_exporter reads RSS from /proc/pid/status, so it includes phantom memory by default. If your kernel is >= 4.14, you can extend collection to read smaps_rollup and expose LazyFree as a separate gauge. Subtract it from RSS in your alerting rules and your OOM capacity alarms stop firing on ghosts.
Kerrisk’s The Linux Programming Interface covers madvise(2) and the anonymous memory model in chapter 21. The decay behavior is a jemalloc concern rather than a kernel one; the jemalloc wiki’s “Background Threads” page has the canonical explanation of the dirty/muzzy split and why both timers exist.