brk and mmap as Allocator Backends
The ptmalloc machinery — chunks, bins, arenas — all operates on memory the allocator has already obtained from the kernel. There are only two ways to get anonymous memory from Linux, and glibc uses both, for different purposes. The
main_arenagrows its heap contiguously withbrk/sbrk, nudging the “program break” upward to enlarge one linear region. Large requests and every secondary arena usemmap, which creates independent virtual-memory regions anywhere in the address space. The choice between them turns on themmapthreshold — 128 KiB by default, and dynamic, in that glibc raises it whenever you free a large block. Just as important is the reverse direction: memory flows back to the kernel only under conditions the calling program does not control, which is why a process’s resident memory so often stays high long afterfree— and, less famously, why it sometimes drops all by itself. This note covers both backends, the thresholds that pick between them, the return path, and what the Linux kernel actually does with each system call underneath.
Two authorities, and which one governs which claim
This note straddles a boundary, so it says explicitly which side each claim comes from.
- glibc side — when
mallocchoosesbrkovermmap, how the thresholds move, howfreedecides to trim, whatmalloc_trimdoes. Read from theglibc-2.43release tag onsourceware.org(malloc/malloc.c,malloc/arena.c,elf/dl-tunables.list,elf/dl-tunables.c,manual/tunables.texi,sysdeps/generic/malloc-machine.h), withglibc-2.42used to date changes.- Linux side — what the
brkandmmapsystem calls actually do to the process address space, how VMAs are created, extended and split, what locks are taken, and what limits apply. Read from Linux v6.12, this vault’s pinned long-term kernel (as of 2026-09-11 the mainline is 7.3-rc2 and 6.12.109 is one of six long-term series, per kernel.org/releases.json; 6.12 is the vault’s pin, not the only LTS). Files:mm/mmap.c,mm/vma.c,include/linux/mm.h. There is nomm/brk.cin v6.12 —SYSCALL_DEFINE1(brk, …)lives inmm/mmap.cand the VMA helpers it calls were moved intomm/vma.cduring the 6.10–6.12 VMA refactor.- Measurements were run on the host that wrote this note: glibc 2.43 (Fedora
glibc-2.43-8.fc44), GCC 16.2.1, Linux 7.1.13, x86-64. Where the running kernel differs from v6.12 in a way that matters, it is called out.What this note owns. ptmalloc and glibc malloc owns chunks and bins; malloc Arenas and Thread Caching owns arenas as units of concurrency and the per-thread cache. This note owns the bottom of the allocator: the boundary where
sysmallocstops manipulating chunk metadata and issues a system call, and the return path wherefree,systrim,heap_trimandmalloc_trimhand pages back.
Two claims chased down rather than assumed
Transparent huge pages and heap growth are architecture-specific.
sysmallocreplaces its page-alignment step with an alignment tomp_.thp_pagesizewhenever that is non-zero, which would round everybrkextension up to a huge page. On x86-64 it never fires:sysdeps/generic/malloc-hugepages.hdefinesDEFAULT_THP_PAGESIZEas 0, and the only architecture that overrides it in 2.43 is aarch64, wheresysdeps/unix/sysv/linux/aarch64/malloc-hugepages.hsets(1UL << 21)= 2 MiB. The 2.43NEWSconfirms the intent — “On AArch64, support for 2MB transparent huge pages has been enabled by default in malloc”.thp_initshort-circuits onDEFAULT_THP_PAGESIZE == 0, so on x86-64mp_.thp_pagesizestays 0 and heap growth is plain page-aligned, which is what the measurements below show. If you are reading this on an ARM server, expect thebrkincrements to be 2 MiB-granular instead.The
sbrk-failure fallback is reachable, and it was exercised. Mapping aPROT_NONEwall immediately above the program break withMAP_FIXED_NOREPLACEand then allocating past it produces this trace (gdbdisables ASLR, which is what makes the wall land where it is wanted):== now allocate past the wall brk addr=0x456000 <- normal growth brk addr=0x486000 brk addr=0x4b6000 brk addr=0x4e6000 brk addr=0x516000 <- now up against the wall mmap addr=0 len=1048576 <- sysmalloc_mmap_fallback, exactly MMAP_AS_MORECORE_SIZE brk addr=0x517000 <- still tries, still fails mmap addr=0 len=1048576 brk addr=0x517000 mmap addr=0 len=1048576Each fallback mapping is exactly
MMAP_AS_MORECORE_SIZE= 1,048,576 bytes. Note also whatmallinfo2reports afterwards:hblks=0 hblkhd=0K arena=4808K— the fallback regions are counted as arena, not as mmapped chunks, because they areMORECOREsubstitutes rather thanIS_MMAPPEDallocations. A monitoring script that infers “this process is not usingmmap” fromhblks == 0would be wrong here.
Two Ways to Get Memory, and Why glibc Uses Both
Linux hands anonymous (not file-backed) memory to user space through two mechanisms with very different shapes.
brk/sbrk manipulate a single boundary. Every process has a program break — “the first location after the end of the uninitialized data segment,” i.e. the top of the classic heap (brk(2)). brk(addr) sets the break to an absolute address; sbrk(increment) moves it by a relative amount and returns the old break. Raising the break enlarges one contiguous region growing upward from the end of the BSS; lowering it shrinks that region and can return memory to the kernel. This is cheap and simple, but it is fundamentally one linear arena: you can only extend or trim at the top, and you can never punch a hole in the middle.
mmap (with MAP_PRIVATE | MAP_ANONYMOUS) creates an independent mapping of a requested size anywhere the kernel chooses in the address space, and munmap destroys it, returning its pages to the kernel immediately and completely. mmap regions are independent — you can create and destroy them in any order — but each is a separate kernel VMA (virtual memory area) with syscall and page-table overhead.
glibc exploits the complementary strengths. The main_arena uses brk (through the internal MORECORE/sbrk path) for its heap, because a single growing region is efficient for the steady stream of small allocations most programs make. Secondary arenas use mmap because you cannot have many independent brk-style heaps — there is only one program break — so each additional arena must be its own mmap’d region (a heap_info block; see malloc Arenas and Thread Caching). And individual large allocations use a dedicated mmap regardless of arena, so that freeing one big buffer can munmap it straight back to the kernel instead of stranding it in a bin.
flowchart TB REQ["malloc(n)"] DEC{"n ≥ mmap_threshold<br/>(default 128 KiB)?"} REQ --> DEC DEC -->|"yes (large)"| MM["dedicated mmap()<br/>chunk flagged IS_MMAPPED<br/>→ munmap on free"] DEC -->|"no (small/medium)"| ARENA{"which arena?"} ARENA -->|"main"| BRK["grow via sbrk / brk<br/>(one contiguous heap)"] ARENA -->|"secondary"| HEAP["grow the mmap'd heap_info region<br/>(64 MiB max on 64-bit)"]
How a request picks a backend. What it shows: requests at or above the (dynamic) mmap threshold get their own mmap and are munmap’d on free; smaller requests are served from an arena heap, which grows via sbrk for the main arena and by extending its mmap’d region for a secondary arena. The insight to take: only the large-mmap path reliably returns memory to the kernel on free; small allocations live in arena heaps that shrink only under narrow conditions — the root of “RSS won’t go down.”
The diagram above simplifies one thing, and the simplification matters
It draws the threshold test as the first thing
mallocdoes. It is not.mp_.mmap_thresholdis consulted only insidesysmalloc, which_int_malloccalls only after the tcache, all the bins, and the top chunk have failed to satisfy the request. A 1 MiB request lands onmmapin practice because a 1 MiB hole rarely exists in the arena — not becausemallocchecked its size up front. Make the hole exist and themmapnever happens. The measurement below shows exactly that: withMALLOC_TOP_PAD_=1048576the main arena has a megabyte of slack in its top chunk, and a 130 KiB request — comfortably over the 128 KiB threshold — is served from the heap with no system call at all. This is whatmallopt(3)means by “For allocations … that can’t be satisfied from the free list, the memory-allocation functions employmmap(2)”.
brk and mmap compared, axis by axis
The two backends are not interchangeable, and almost every surprising thing glibc does with memory follows from one of the rows below. Values are glibc 2.43 on 64-bit Linux.
| Axis | brk / sbrk (main arena heap) | mmap (large chunks, and every secondary arena) |
|---|---|---|
| How many regions | exactly one, per process | up to mp_.n_mmaps_max = 65,536 concurrent mapped chunks, plus one mapping per sub-heap |
| Granularity of growth | one moving boundary, upward only | whole independent mappings, created and destroyed in any order |
| Alignment of what you get back | none — wherever the break happens to be; glibc aligns the chunk itself | page-aligned by the kernel; glibc must therefore waste up to a page per mapped chunk, and sub-heaps are force-aligned to 64 MiB |
| Per-allocation overhead | amortised: one brk covers many chunks (see M_TOP_PAD) | one mmap and one munmap syscall per large chunk, plus a fresh set of zero-fill page faults on first touch |
| Kernel bookkeeping | usually extends an existing VMA (do_brk_flags merges) | usually creates a new VMA, counted against vm.max_map_count |
Returning memory on free | only the contiguous free run at the very top can be given back, via systrim | complete and immediate: munmap_chunk unmaps the whole thing |
| Reclaiming interior free space | not possible with brk; needs madvise(MADV_DONTNEED), which only malloc_trim issues | not applicable — there is no interior; each chunk is its own mapping |
| Fragmentation behaviour | freed chunks are coalesced and reused, so churn is cheap; but one live chunk at a high address pins everything below it | zero external fragmentation between large chunks; but each is rounded up to a page, and address space can still fragment |
| Effect on the freed data | freed chunks keep their contents until reused | contents are gone; a re-mmap of the same size gives you fresh zero pages, which the kernel must zero |
| Thread safety | there is one program break for the whole process, so only the main arena may use it; concurrent brk calls serialise on the kernel’s mmap_write_lock | any arena, any thread; still serialises on mmap_write_lock inside the kernel |
| Failure mode | sbrk returns (void *) -1 if blocked (something mapped above the break, or RLIMIT_DATA); glibc falls back to mmap and marks the arena non-contiguous | MAP_FAILED; malloc returns NULL |
| Visible as | [heap] in /proc/pid/maps | unnamed rw-p regions |
Two rows deserve a second look. “Extends an existing VMA” is the real reason brk is cheap: raising the break by 128 KiB normally does not add a kernel object at all, it just widens one. A large mmap, by contrast, allocates a vm_area_struct, inserts it into the mm’s maple tree, and counts against vm.max_map_count — which is why glibc caps concurrent mapped chunks at DEFAULT_MMAP_MAX = 65536, a number chosen to sit just above the kernel’s historic DEFAULT_MAX_MAP_COUNT of USHRT_MAX - 5 = 65,530 (include/linux/mm.h, unchanged from v6.12 through v7.1). Distributions now raise the sysctl far above that — this host reports vm.max_map_count = 1048576 — but the glibc constant was sized against the kernel default.
“The kernel must zero it” is the row that most often decides a benchmark. mmap of anonymous memory is contractually zero-filled, so every page of a fresh large allocation costs a page fault plus a clear_page the first time it is touched. Reusing a heap chunk costs neither. That is the entire economic argument for the dynamic threshold described next.
flowchart LR subgraph AS["One process's address space (low → high)"] direction TB TEXT["ELF text + data + bss"] HEAP["<b>[heap]</b> — the main arena<br/>grown by brk, one contiguous run<br/>chunk · chunk · chunk · … · <b>top chunk</b>"] GAP1[" … unmapped … "] MM1["mmap'd large chunk<br/>IS_MMAPPED set, munmap'd on free"] MM2["secondary arena sub-heap<br/>64 MiB-aligned, rw-p prefix"] MM3["---p tail:<br/>reserved, PROT_NONE,<br/>never counted in RSS"] LIBS["libc.so, ld.so, thread stacks"] STACK["main stack (grows down)"] end TEXT --> HEAP --> GAP1 --> MM1 --> MM2 --> MM3 --> LIBS --> STACK BRKP(["program break<br/>moves up and down<br/>= the top of [heap]"]) -.-> HEAP MMAPB(["mmap_base<br/>kernel picks addresses here,<br/>randomised by ASLR"]) -.-> MM1
Where each backend’s memory physically lands. What it shows: the brk heap is a single run immediately above the program’s data segment whose only movable edge is its top, while everything mmap produces lives in the middle of the address space among the shared libraries, in independent pieces. The insight to take: the geometry is the policy. There is nowhere for the brk heap to grow except upward and nowhere to shrink except downward from its top, which is why a single live chunk near the top can pin megabytes; mmap regions have no such constraint, which is why glibc routes anything large — and every arena after the first — through mmap instead.
The mmap Threshold, and Why It’s Dynamic
The switch point is M_MMAP_THRESHOLD. Requests at or above it get a dedicated mmap; smaller ones come from an arena heap. The default is 128 KiB (DEFAULT_MMAP_THRESHOLD = 128 * 1024, from malloc/malloc.c). Crucially, glibc makes this threshold dynamic. As mallopt(3) explains, “the initial value of the threshold is 128×1024, but when blocks larger than the current threshold and less than or equal to DEFAULT_MMAP_THRESHOLD_MAX are freed, the threshold is adjusted upward to the size of the freed block.” In plain terms: when the program frees a large block, glibc infers that blocks of that size are being reused, so it stops mmap-ing them (which would munmap and re-mmap on every cycle — two syscalls plus page faults each time) and instead lets that size be served from the heap, where a freed chunk can be handed straight back. The threshold ratchets up to whatever large size the program churns, capped at DEFAULT_MMAP_THRESHOLD_MAX — 512 KiB on 32-bit, and 4 * 1024 * 1024 * sizeof(long) = 32 MiB on 64-bit. This adaptivity is why a program allocating and freeing 1 MiB buffers in a loop does not pay two syscalls per iteration after the first few.
The code is four lines, in __libc_free’s mmapped-chunk branch (malloc/malloc.c, glibc-2.43):
/* See if the dynamic brk/mmap threshold needs adjusting. */
if (!mp_.no_dyn_threshold
&& chunksize_nomask (p) > mp_.mmap_threshold
&& chunksize_nomask (p) <= DEFAULT_MMAP_THRESHOLD_MAX)
{
mp_.mmap_threshold = chunksize (p); /* raise to the size just freed */
mp_.trim_threshold = 2 * mp_.mmap_threshold; /* and keep trim twice as high */
}
munmap_chunk (p);Three things to take from it. The adjustment happens on free of an mmap’d chunk, not on malloc — the allocator learns from what you give back, because a large block that is freed is evidence that another of that size is probably coming. The trim threshold is dragged along at exactly twice the mmap threshold, so raising the cutover also makes the heap more reluctant to shrink; the two knobs are not independent even though they look it. And the whole thing is gated on mp_.no_dyn_threshold.
Setting any of four parameters permanently disables the adaptation. mallopt(3) names them: “Dynamic adjustment of the mmap threshold is disabled if any of the M_TRIM_THRESHOLD, M_TOP_PAD, M_MMAP_THRESHOLD, or M_MMAP_MAX parameters is set.” Reading the source confirms all four — do_set_trim_threshold, do_set_top_pad, do_set_mmap_threshold and do_set_mmaps_max each end with mp_.no_dyn_threshold = 1; before returning. (An earlier draft of this note listed only three and omitted M_MMAP_MAX; the environment variable MALLOC_MMAP_MAX_ freezes the threshold just as effectively as the other three.) This is a genuine foot-gun: exporting MALLOC_TRIM_THRESHOLD_=0 to reduce RSS also pins the mmap threshold at 128 KiB forever, so a program that churns 1 MiB buffers now pays an mmap + munmap pair and a fresh set of zero-fill faults on every single iteration.
flowchart TB START(["mmap_threshold = 128 KiB<br/>trim_threshold = 128 KiB<br/>no_dyn_threshold = 0"]) A["malloc(n): arena cannot satisfy it<br/>and n ≥ mmap_threshold"] B["sysmalloc_mmap(): dedicated mmap<br/>chunk flagged IS_MMAPPED"] C["free(p) on that chunk"] D{"no_dyn_threshold == 0<br/>and mmap_threshold < chunksize(p) ≤ 32 MiB?"} E["mmap_threshold = chunksize(p)<br/>trim_threshold = 2 × that"] F["munmap_chunk(p): region returned to kernel"] G["next malloc(n) of the same size is now<br/><b>below</b> the threshold → served from the heap,<br/>no syscall, no page faults, contents reused"] KILL(["mallopt / tunable sets any of<br/>M_TRIM_THRESHOLD · M_TOP_PAD ·<br/>M_MMAP_THRESHOLD · M_MMAP_MAX"]) START --> A --> B --> C --> D D -->|yes| E --> F --> G D -->|no| F KILL -->|"no_dyn_threshold = 1<br/>(permanent, process-wide)"| D
The dynamic-threshold feedback loop. What it shows: the ratchet is driven by free, raises the cutover to exactly the size just released, drags the trim threshold to twice that, and is switched off permanently — not just for the parameter you set — by any of four mallopt parameters. The insight to take: glibc is inferring your allocation size distribution from your frees, once, upward only. It never ratchets back down, so a program that allocates one 30 MiB buffer early leaves the threshold at 30 MiB for the rest of its life, and every allocation below that size thereafter stays in the heap where free cannot return it to the kernel.
The four backend tunables, verified at glibc-2.43
mallopt parameter | Tunable | Env alias | Effective default | Source of truth |
|---|---|---|---|---|
M_MMAP_THRESHOLD | glibc.malloc.mmap_threshold | MALLOC_MMAP_THRESHOLD_ | 128 KiB, then dynamic up to DEFAULT_MMAP_THRESHOLD_MAX | #define DEFAULT_MMAP_THRESHOLD DEFAULT_MMAP_THRESHOLD_MIN, = 128 * 1024 |
| — (the ceiling on the above) | — | — | 512 KiB (32-bit) / 32 MiB (64-bit) | DEFAULT_MMAP_THRESHOLD_MAX = 4 * 1024 * 1024 * sizeof(long) |
M_TRIM_THRESHOLD | glibc.malloc.trim_threshold | MALLOC_TRIM_THRESHOLD_ | 128 KiB, then dragged to 2 × mmap_threshold | #define DEFAULT_TRIM_THRESHOLD (128 * 1024) |
M_TOP_PAD | glibc.malloc.top_pad | MALLOC_TOP_PAD_ | 128 KiB — not 0, see below | sysdeps/generic/malloc-machine.h, and default: 131072 in elf/dl-tunables.list |
M_MMAP_MAX | glibc.malloc.mmap_max | MALLOC_MMAP_MAX_ | 65,536 concurrent mapped chunks; 0 disables mmap for chunks entirely | #define DEFAULT_MMAP_MAX (65536) |
| — (trim attempt gate) | — | — | 64 KiB | #define ATTEMPT_TRIMMING_THRESHOLD (65536UL) |
M_TOP_PAD's default is a trap for source readers
malloc/malloc.ccontains, in plain sight, a comment saying “the default is 0” and the line#define DEFAULT_TOP_PAD (0). Both are misleading, and reading only that file will give you the wrong answer. The#defineis guarded by#ifndef DEFAULT_TOP_PAD, andsysdeps/generic/malloc-machine.h— pulled in first — already defines it as 131072. The comment is inherited from dlmalloc, whose default really was 0; glibc has overridden it for decades. Independently,elf/dl-tunables.listgives thetop_padtunabledefault: 131072, which is whatld.so --list-tunablesreports:$ /lib64/ld-linux-x86-64.so.2 --list-tunables | grep top_pad glibc.malloc.top_pad: 0x20000 (min: 0x0, max: 0xffffffffffffffff)
0x20000is 131072. The measurement below confirms the runtime behaviour matches. This is the one place in this subsystem where the manual is right and the obvious source reading is wrong — the mirror image of the stale-documentation problems catalogued in malloc Arenas and Thread Caching.A subtlety worth recording, since it took a source dive to settle: the tunable’s
default:value does not causedo_set_top_padto run at start-up.__tunable_get_valinelf/dl-tunables.cfires a callback onlyif (cur->initialized && callback != NULL), andscripts/gen-tunables.awkemitsfalsefor theinitializedfield of every entry — it becomes true only when the environment actually supplies a value. So on a default runmp_.top_padis 131072 because of thesysdepsheader, andmp_.no_dyn_thresholdstays 0, which is why the dynamic mmap threshold still works out of the box. Had the callback fired, it would have silently disabled the ratchet in every process on the system.
The Return Path: Why Freed Memory Stays Resident
The folklore version of this is “free never gives memory back”. That is too strong, and believing it will make you misdiagnose real problems. The accurate statement is narrower: free returns memory to the kernel only when the freed space happens to end up in the right shape, and the shape is decided by where the live objects are. The measurements later in this note show a workload where freeing 20,000 chunks drops RSS from 82 MiB to 2 MiB with no malloc_trim at all, and a nearly identical workload where it drops by nothing.
There are exactly three channels, and each has a precondition.
-
mmap’d large chunks (IS_MMAPPEDset) aremunmap’d immediately onfree— clean, complete, unconditional. This is the only channel with no precondition at all, and it is why the mmap threshold matters so much for long-running processes. -
The top of an arena’s heap can be trimmed. This is the conditional one, and in glibc 2.43 it is gated twice.
_int_free_chunkcalls_int_free_maybe_trim (av, size), which first checks the size of the chunk that was just freed againstATTEMPT_TRIMMING_THRESHOLD(64 KiB, described in the source as “half the default trim threshold as a compromise heuristic to only attempt trimming if it is likely to release a significant amount of memory”). Only if that passes does it look at the arena:if (size >= ATTEMPT_TRIMMING_THRESHOLD) { if (av == &main_arena) { if (chunksize (av->top) >= mp_.trim_threshold) /* second gate */ systrim (mp_.top_pad, av); /* lower the break */ } else { /* Always try heap_trim, even if the top chunk is not large, because the corresponding heap might go away. */ heap_info *heap = heap_for_ptr (top (av)); heap_trim (heap, mp_.top_pad); } }For the main arena both gates must pass: a chunk of at least 64 KiB was freed and the wilderness now exceeds
M_TRIM_THRESHOLD(default 128 KiB, or twice the dynamic mmap threshold).systrimthen lowers the program break with a negativesbrk, keepingmp_.top_padbytes of headroom. Crucially this only works on the contiguous free run at the very top: a single in-use chunk near the high end of the heap pins everything below it, because the break cannot move past it. That is the essence of fragmentation-induced RSS retention — one long-lived small object at a high address keeps megabytes of otherwise-free heap resident.For a secondary arena the second gate is skipped deliberately, because a sub-heap that has become entirely free can be
munmapped outright rather than merely shrunk, and that is worth checking for even when the top chunk is small.Note the subtle consequence of the first gate: a program that only ever frees small chunks never even attempts to trim, no matter how much free space accumulates at the top, until some allocation of ≥ 64 KiB is eventually freed. Coalescing is what usually rescues it — twenty thousand adjacent 4 KiB chunks merge into one enormous free chunk as they are freed, and the chunk that finally crosses 64 KiB triggers the trim. Break the coalescing (keep every other chunk alive) and the trim never fires.
-
malloc_trim(size_t pad)(malloc_trim(3)) forces the issue, leavingpadbytes of headroom — “If this argument is 0, only the minimum amount of memory is maintained at the top of the heap (i.e., one page or less).” Modern glibc’smalloc_trimdoes more than lower the break: it walks the free chunks within every arena and usesmadvise(MADV_DONTNEED)on whole free pages in their interiors, so it reclaims fragmented space that break-lowering structurally cannot. It is never called on a schedule — the program, or a runtime like Ruby that calls it during garbage collection, must invoke it. The measurements below quantify exactly how much of a fragmented heap it can and cannot recover.
flowchart TB F["free(p)"] MT{"chunk is IS_MMAPPED?"} UN["munmap_chunk(p)<br/>maybe raise mmap_threshold first<br/><b>pages returned, unconditionally</b>"] TC{"size < tcache_max_bytes<br/>and a slot is free?"} PUT["tcache_put: stays in this thread's cache<br/><b>nothing returned, no lock</b>"] IF["_int_free_chunk: lock the arena,<br/>coalesce with prev/next,<br/>put on unsorted bin or merge into top"] G1{"freed size ≥ 64 KiB<br/>ATTEMPT_TRIMMING_THRESHOLD"} STOP["return; pages stay resident"] MA{"which arena?"} G2{"chunksize(av->top) ≥ trim_threshold?"} TRIM["systrim(top_pad, av):<br/>negative sbrk, break lowered<br/><b>pages returned</b>"] HT["heap_trim(heap, top_pad):<br/>shrink_heap via MADV_DONTNEED,<br/>munmap an entirely-empty sub-heap<br/><b>pages returned</b>"] F --> MT MT -->|yes| UN MT -->|no| TC TC -->|yes| PUT TC -->|no| IF IF --> G1 G1 -->|no| STOP G1 -->|yes| MA MA -->|main_arena| G2 MA -->|secondary| HT G2 -->|no| STOP G2 -->|yes| TRIM
Every path free can take, and which ones return memory to the kernel. What it shows: of the five outcomes, only three give pages back, and two of those are gated on conditions the calling program does not control. The insight to take: a program that allocates and frees only small objects can run forever and never once reach a systrim call, because the first gate asks about the size of the chunk being freed, not about how much free space has accumulated. Coalescing is what usually gets it over that gate — and coalescing is exactly what fragmentation prevents.
MADV_DONTNEED vs. Actually Unmapping
“Giving memory back” is not one operation but four, and they differ in what survives, what RSS does, and what the next touch costs (madvise(2)).
| Mechanism | Mapping after | Physical pages | VmRSS | VmSize | Next touch | Who uses it |
|---|---|---|---|---|---|---|
munmap(addr, len) | gone — the range faults with SIGSEGV | freed immediately | drops now | drops now | segfault; the allocator must mmap again | glibc munmap_chunk, for every IS_MMAPPED chunk on free |
brk lowered (systrim) | the [heap] VMA shrinks | freed immediately | drops now | drops now | segfault (or the heap regrows first) | glibc systrim, when the top chunk exceeds M_TRIM_THRESHOLD |
madvise(…, MADV_DONTNEED) | kept, still valid | freed immediately | drops now | unchanged | anonymous fault → a fresh zero page | glibc malloc_trim for interior free pages; shrink_heap on most platforms |
madvise(…, MADV_FREE) (Linux 4.5+) | kept, still valid | marked reclaimable; freed lazily, under pressure | drops only when the kernel reclaims | unchanged | old contents if not yet reclaimed, zeroes if it was | jemalloc; not glibc’s default path |
mmap(addr, len, PROT_NONE, MAP_FIXED) | kept but inaccessible | freed immediately | drops now | unchanged | SIGSEGV — a deliberate tripwire | glibc shrink_heap where check_may_shrink_heap() is true |
Reading across the rows: the first two are the only ones that reduce virtual size, and they are the only ones that make the address range unusable. The middle three all keep the address space, which is exactly what an allocator wants — it can hand the same addresses out again without another mmap, without a VMA split, and without disturbing the chunk layout that its bins point into. The price is uniformly the same: whatever was in those pages is gone, and the next touch is a page fault.
MADV_FREE is worth understanding even though glibc does not use it by default, because it is why RSS on jemalloc- and Go-based services looks “wrong”. The manual page’s wording is precise: “the freeing could be delayed until memory pressure occurs”, and the operation is cancelled if the program writes to the page before the kernel gets there. So RSS is an upper bound on what the process would keep under pressure, not a measure of what it is using. Alerting on RSS for such a process produces false pages; alerting on cgroup memory pressure does not.
The practical upshot for glibc specifically: whether free reduces RSS depends on which backend served the allocation — mmap’d chunks always give their pages back, heap chunks only if they coalesce into a big enough run at the trimmable top — and interior fragmentation can pin resident memory indefinitely unless the program explicitly calls malloc_trim.
The Kernel Side: What brk and mmap Actually Do (Linux v6.12)
Everything above is glibc’s view. Underneath, both backends are manipulating the same object: the process’s list of virtual memory areas (VMAs), each a struct vm_area_struct describing one contiguous range of the address space with uniform permissions and backing. In v6.12 these are held in a maple tree rooted at mm->mm_mt, and the syscalls that change them are mm/mmap.c with most of the VMA-manipulation helpers factored out into mm/vma.c. There is no mm/brk.c — a reasonable guess, since brk feels like it deserves its own file, but SYSCALL_DEFINE1(brk, unsigned long, brk) is the first substantial function in mm/mmap.c.
brk is a VMA extension, not a new mapping
The whole of sys_brk is worth walking, because five separate facts that glibc has to work around are visible in it.
SYSCALL_DEFINE1(brk, unsigned long, brk)
{
if (mmap_write_lock_killable(mm)) /* (1) whole-mm write lock */
return -EINTR;
origbrk = mm->brk;
min_brk = mm->start_brk; /* (2) cannot go below the start */
if (brk < min_brk)
goto out;
if (check_data_rlimit(rlimit(RLIMIT_DATA), brk, mm->start_brk,
mm->end_data, mm->start_data))
goto out; /* (3) RLIMIT_DATA caps the heap */
newbrk = PAGE_ALIGN(brk);
oldbrk = PAGE_ALIGN(mm->brk);
if (oldbrk == newbrk) { mm->brk = brk; goto success; }
if (brk <= mm->brk) { /* shrinking: always allowed */
... do_vmi_align_munmap(&vmi, brkvma, mm, newbrk, oldbrk, &uf, true);
goto success_unlocked;
}
/* growing: */
next = vma_find(&vmi, newbrk + PAGE_SIZE + stack_guard_gap);
if (next && newbrk + PAGE_SIZE > vm_start_gap(next))
goto out; /* (4) something is in the way */
brkvma = vma_prev_limit(&vmi, mm->start_brk);
if (do_brk_flags(&vmi, brkvma, oldbrk, newbrk - oldbrk, 0) < 0)
goto out;
mm->brk = brk;
success:
mmap_write_unlock(mm);
return brk;
out:
mm->brk = origbrk; /* (5) failure returns the OLD break */
mmap_write_unlock(mm);
return origbrk;
}(1) It takes mmap_write_lock(mm). Growing the heap by one page is, to the kernel, the same class of operation as mmap or munmap: it takes the process-wide address-space write lock. Every page fault in every thread that needs to read the VMA tree contends with it. Linux 6.x mitigates this for faults with per-VMA locks (vma_start_read), and note that sys_brk calls vma_start_write(vma) inside do_brk_flags to exclude those readers, but the write side is still a global serialisation point for the process. This is the real reason M_TOP_PAD exists: glibc would rather over-ask once than take this lock repeatedly.
(2) and (3) are why sbrk can fail for reasons that have nothing to do with available memory. RLIMIT_DATA (ulimit -d) caps the break directly through check_data_rlimit. It also, less obviously, caps private writable mappings: may_expand_vm in the same file tests is_data_mapping(flags) && mm->data_vm + npages > rlimit(RLIMIT_DATA) >> PAGE_SHIFT, so a low ulimit -d makes mmap fail as well as brk. The kernel prints a pr_warn_once naming the process when this bites, and offers the ignore_rlimit_data boot option as an escape hatch — the presence of a dedicated workaround for Valgrind a few lines above suggests how often this has confused people. RLIMIT_AS (ulimit -v) is checked in the same function against mm->total_vm, which is why it interacts so badly with secondary arenas’ 64 MiB reservations.
(4) The stack guard gap. A growing brk must stop stack_guard_gap pages short of the next VMA. Combined with ASLR placing the heap and the mmap region independently, this is the concrete mechanism behind “sbrk failed because something is mapped above the break” — the case glibc handles by falling back to mmap and calling set_noncontiguous(av).
(5) brk reports failure by returning the unchanged break, never -1 with an errno. That is why the glibc wrapper is written the way it is: __sbrk remembers __curbrk, issues the syscall, and infers failure by comparing the result. Any code that checks brk()’s return value against -1 is checking for something that cannot happen on Linux.
The most important line, though, is the call to do_brk_flags, whose comment reads “Extend the brk VMA from addr to addr + len. If the VMA is NULL or the flags do not match then create a new anonymous VMA.” The implementation:
if (mm->map_count > sysctl_max_map_count)
return -ENOMEM;
/* Expand the existing vma if possible */
if (vma && vma->vm_end == addr) {
VMG_STATE(vmg, mm, vmi, addr, addr + len, flags, PHYS_PFN(addr));
vmg.prev = vma;
vmg.merge_flags = VMG_FLAG_JUST_EXPAND;
if (vma_merge_new_range(&vmg))
goto out; /* no new VMA at all */
}
/* otherwise allocate one */
vma = vm_area_alloc(mm);
...
mm->map_count++;So in the normal case raising the break allocates no kernel object whatsoever — it widens vm_end on the existing [heap] VMA. That is the structural asymmetry between the two backends: brk is amortised over the whole heap, while every large mmap adds a VMA that must be allocated, inserted into the maple tree, counted against vm.max_map_count, and later removed and freed. It also explains why [heap] appears as a single line in /proc/pid/maps no matter how many times it has grown, while a process that mmaps many large chunks accumulates many lines.
sequenceDiagram autonumber participant App as application participant M as glibc malloc participant K as Linux mm (v6.12) participant VMA as VMA tree (mm_mt) App->>M: malloc(120 KiB) M->>M: tcache miss, bins empty, top chunk too small M->>M: sysmalloc: 120K < mmap_threshold → MORECORE path<br/>size = nb + top_pad(128K) + MINSIZE, page-aligned M->>K: brk(new_break) K->>K: mmap_write_lock(mm) · RLIMIT_DATA · guard-gap check K->>VMA: do_brk_flags → vma_merge_new_range<br/>VMG_FLAG_JUST_EXPAND VMA-->>K: the heap VMA is widened, map_count unchanged K-->>M: new break M-->>App: pointer into the heap (no pages faulted in yet) App->>M: malloc(130 KiB) M->>M: sysmalloc: 130K ≥ mmap_threshold → sysmalloc_mmap M->>K: mmap(NULL, 135168, PROT_READ+PROT_WRITE,<br/>MAP_PRIVATE+MAP_ANONYMOUS) K->>VMA: allocate vm_area_struct, insert, map_count++ VMA-->>K: new independent VMA K-->>M: address M->>M: set IS_MMAPPED on the chunk header M-->>App: pointer into its own mapping App->>M: free(that 130 KiB block) M->>M: chunk_is_mmapped → raise mmap_threshold to 135168,<br/>trim_threshold to 270336 M->>K: munmap(addr, 135168) K->>VMA: remove VMA, free it, map_count-- Note over App,VMA: the next malloc(130 KiB) is now below the threshold<br/>and never reaches the kernel at all
One trip through each backend, from the call site to the VMA tree. What it shows: the sub-threshold request widens an existing VMA and the super-threshold request creates and then destroys one, after which the allocator rewrites its own threshold so the next identical request takes the cheap path. The insight to take: the expensive part of the mmap route is not the mmap — it is the VMA churn plus the zero-fill faults on first touch, paid again on every cycle. The dynamic threshold exists to notice the cycle and stop paying.
What MADV_DONTNEED costs the kernel, and why glibc uses it anyway
When malloc_trim or shrink_heap issues madvise(addr, len, MADV_DONTNEED), the kernel zaps the page-table entries for that range and frees the underlying pages back to the page allocator immediately, while leaving the VMA in place. The mapping stays valid, so the allocator can keep the address range and hand it out again later — but the first touch of each page afterwards is a fresh anonymous page fault that allocates and zeroes a page. For an allocator this is exactly the right trade: it keeps its address space (no VMA churn, no re-mmap, no fragmentation of the arena’s layout) and gives back only the physical pages. For the program, it means a heap that has been trimmed is slower on its next pass over the same memory, and it means any data still sitting in those free chunks is irrecoverably gone — which is fine, because they were free.
shrink_heap in malloc/arena.c actually picks between two mechanisms depending on a compile-time property of the platform:
if (__glibc_unlikely (check_may_shrink_heap ()))
{
/* re-map the tail PROT_NONE: frees the pages AND removes access */
if ((char *) MMAP ((char *) h + new_size, diff, PROT_NONE, MAP_FIXED) == MAP_FAILED)
return -2;
h->mprotect_size = new_size;
}
else
__madvise ((char *) h + new_size, diff, MADV_DONTNEED);The MAP_FIXED re-map is stronger — it both releases the pages and makes the range inaccessible, so a stray write into trimmed heap faults instead of silently resurrecting a zero page — but it costs a VMA split. MADV_DONTNEED is cheaper and is the common path.
Which branch runs is decided at runtime, once, by check_may_shrink_heap() in sysdeps/unix/sysv/linux/malloc-sysdep.h. It is true in exactly two situations: the process is a secure exec (setuid/setgid — so that freed heap pages “immediately cease to be accessible”), or /proc/sys/vm/overcommit_memory contains 2, meaning strict overcommit accounting is in force and glibc wants to give the commit charge back rather than merely the physical pages. The check reads that proc file exactly once and caches the answer in a function-local static. On an ordinary Linux system — this host reports vm.overcommit_memory = 0 — the MADV_DONTNEED branch is the one that runs.
Uncertain
Verify: whether
heap_trimevermunmaps an entirely empty secondary sub-heap in practice, and what that looks like in a trace. Reason: the code path exists (heap_trimdeletes a non-first sub-heap whose top chunk covers the whole heap), but none of the workloads measured for this note emptied a sub-heap completely — each kept live chunks in every arena — so it was read, not observed. To resolve: have a thread allocate enough into a secondary arena to force a second sub-heap, free all of it, and trace for amunmapat a 64 MiB-aligned address. uncertain
How Heaps Actually Grow
The two backends grow differently, and the mechanics explain several behaviors.
The main arena grows through sysmalloc’s MORECORE path, which is sbrk. When the top chunk is too small, glibc requests more than the immediate need — it adds M_TOP_PAD (the “top padding”) to the sbrk increment so it need not call sbrk again on the very next allocation. It also rounds the request up to a page boundary. If sbrk fails (for example, because another mapping now sits directly above the program break and blocks contiguous growth — common once mmap’d regions have been placed by the loader or other allocations), glibc falls back to mmap to obtain a fresh region for the main arena, and continues from there. So even the “brk-backed” main arena can end up with mmap’d extension regions when the address space above the break is obstructed.
A secondary arena cannot use sbrk (there is only one program break), so its heap grows entirely within an mmap’d region. new_heap reserves a full HEAP_MAX_SIZE (64 MiB on 64-bit) block of address space up front — but reserved, not committed: only a small prefix is made readable/writable. As the heap grows, grow_heap mprotects additional pages of the already-reserved region from no-access to read/write (tracked by the mprotect_size field of heap_info in malloc/arena.c), and as it shrinks past the trim threshold, shrink_heap madvises or mprotects pages back. Reserving the whole 64 MiB up front is why a program with many arenas shows large virtual size (VmSize) even when resident memory is modest — the address space is claimed but mostly uncommitted. This is normal and not itself a problem; RSS, not virtual size, is what consumes physical RAM.
The exact growth arithmetic, and the sbrk fallback
The MORECORE branch of sysmalloc in malloc/malloc.c is short enough to quote, and the three lines are worth having in your head because they are what the measurements below confirm:
size = nb + mp_.top_pad + MINSIZE; /* what we need, plus hysteresis */
if (contiguous (av))
size -= old_size; /* the existing top chunk is already ours */
size = ALIGN_UP (size, GLRO(dl_pagesize));
...
brk = (char *) (MORECORE ((long) size));nb is the padded chunk size for the request, MINSIZE (32 bytes on 64-bit) reserves room for the new top chunk’s header, old_size is the existing top chunk which will be merged with the new space, and mp_.top_pad is the hysteresis — the deliberate over-ask that keeps the next few allocations from having to call brk at all. When Transparent Huge Pages are configured such that mp_.thp_pagesize is non-zero, the page-alignment step is replaced by an alignment of the resulting break to the THP page size, so that the heap’s extensions can be backed by huge pages.
If MORECORE returns MORECORE_FAILURE, glibc does not give up. sysmalloc_mmap_fallback obtains a fresh region by mmap — at least MMAP_AS_MORECORE_SIZE (1 MiB) so the fallback is not itself pathologically chatty — labels it " glibc: malloc" with __set_vma_name, and then calls set_noncontiguous (av). That last call is permanent and matters: once the main arena is marked non-contiguous, sysmalloc stops subtracting old_size from future requests (it can no longer assume a new region abuts the old one) and systrim will no longer try to lower the break across the discontinuity. A main arena that has been forced onto mmap once behaves differently for the rest of the process’s life. The same flag is set unconditionally, via __always_fail_morecore, when libc is loaded into a non-default namespace or dlopened from a statically linked program — and whenever explicit huge pages are enabled for malloc. That last case is glibc.malloc.hugetlb set to 2 or more, which makes mp_.hp_pagesize non-zero; __ptmalloc_init then does two things at once:
if (mp_.hp_pagesize > 0 && mp_.hp_pagesize <= heap_max_size ())
{
if (!TUNABLE_IS_INITIALIZED (mmap_threshold))
do_set_mmap_threshold (mp_.hp_pagesize); /* also sets no_dyn_threshold = 1 */
__always_fail_morecore = true; /* never use brk again */
}— so turning on MAP_HUGETLB support silently takes the main arena off brk entirely and disables the dynamic mmap threshold, because do_set_mmap_threshold sets no_dyn_threshold. Neither consequence is documented at the tunable.
How the 64 MiB alignment is actually obtained
The sentence “new_heap reserves a full HEAP_MAX_SIZE block” hides a wrinkle worth knowing, because it doubles the transient address-space cost. mmap gives no control over alignment, so alloc_new_heap in malloc/arena.c maps twice what it needs and throws the rest away:
p1 = (char *) MMAP (NULL, max_size << 1, PROT_NONE, mmap_flags); /* 128 MiB */
if (p1 != MAP_FAILED) {
p2 = (char *) (((uintptr_t) p1 + (max_size - 1)) & ~(max_size - 1)); /* round up */
ul = p2 - p1;
if (ul) __munmap (p1, ul); /* give back the head */
else aligned_heap_area = p2 + max_size; /* it was already aligned: remember the tail */
__munmap (p2 + max_size, max_size - ul); /* give back the tail */
}
...
__mprotect (p2, size, PROT_READ | PROT_WRITE); /* commit only the prefix */The aligned_heap_area optimisation is a nice touch: when the 128 MiB mapping happens to land already aligned, the second 64 MiB half is remembered and handed to the next arena directly, so consecutive arena creations alternate between the expensive and the cheap path. Note also the first MMAP is PROT_NONE, and the comment explains why that matters: “No swap space needs to be reserved for the following large mapping (on Linux, this is the case for all non-writable mappings anyway)” — a PROT_NONE reservation does not count against the overcommit accounting, so reserving 64 MiB per arena is genuinely free even under vm.overcommit_memory=2.
Worked Example: Observing the Backends for Real
All output in this section was produced on glibc 2.43 (Fedora glibc-2.43-8.fc44), GCC 16.2.1, Linux 7.1.13, x86-64. Two notes on method before the results.
First, compile with -fno-builtin-malloc -fno-builtin-free. GCC is permitted to delete a malloc/free pair whose result is unused, and at -O1 and above it does; several of these experiments silently measure nothing without the flag. Second, strace is not installed on every machine, and it is not needed: gdb’s catch syscall does the same job, and mallinfo2(3) reports the allocator’s own view without any tracer at all. Both are used below.
The syscall trace: brk below the threshold, mmap above, brk again after the ratchet
The program marks each step by calling an empty noinline function that gdb breaks on, so the trace interleaves correctly:
__attribute__((noinline)) void mark(const char *s){ __asm__ __volatile__("" :: "r"(s) : "memory"); }
int main(void) {
mark("first printf -- glibc allocates the stdio buffer, heap is created");
printf("(stdio warm-up)\n"); fflush(stdout);
mark("malloc(120K) -- 120 KiB < 128 KiB mmap threshold");
void *a = malloc(120*1024); memset(a, 1, 120*1024);
mark("malloc(130K) -- 130 KiB >= threshold");
void *b = malloc(130*1024); memset(b, 1, 130*1024);
mark("free(b) -- munmap + threshold ratchet");
free(b);
mark("malloc(130K) -- same size, now under the NEW threshold");
void *c = malloc(130*1024); memset(c, 1, 130*1024);
mark("free(c); free(a); malloc_trim(0)");
free(c); free(a); malloc_trim(0);
mark("exit");
}# tr.gdb — a portable stand-in for `strace -e trace=brk,mmap,munmap,madvise`
set confirm off
set pagination off
set debuginfod enabled off
break mark
commands
silent
printf "\n== %s\n", s
continue
end
catch syscall brk mmap munmap madvise
commands
silent
printf "SYS %ld %#lx %ld\n", $orig_rax, $rdi, $rsi
continue
end
run
quitgdb -q -batch -x tr.gdb ./backends, with the raw SYS lines decoded (each syscall reports twice, once at entry and once at exit, so duplicates are folded):
== first printf -- glibc allocates the stdio buffer, heap is created
brk addr=0 <- __sbrk(0): read the current break
brk addr=0x426000 <- first heap growth: +128 KiB of top_pad
== malloc(120K) -- 120 KiB < 128 KiB mmap threshold
<- NO SYSCALL: it fit in the top chunk
== malloc(130K) -- 130 KiB >= threshold
mmap addr=0 len=135168
== free(b) -- munmap + threshold ratchet
munmap addr=0x7ffff7f99000 len=135168
== malloc(130K) -- same size, now under the NEW threshold
brk addr=0x464000 <- served from the heap this time
== free(c); free(a); malloc_trim(0)
brk addr=0x426000 <- systrim lowers the break
brk addr=0x406000 <- malloc_trim(0) takes it down further
== exit
Every claim in this note is in those ten lines.
- The 130 KiB request produced
mmap(NULL, 135168, …)— the request rounded up to a page, plus the chunk header.freeproduced a matchingmunmapof exactly the same length. - The second identical 130 KiB request produced a
brk, not anmmap. The threshold ratcheted up to 135168 when the first block was freed, so the same size is now “small”. This is the dynamic threshold, observed rather than asserted. - The 120 KiB request — comfortably under the threshold — produced no system call at all, because
top_padhad already bought 128 KiB of slack. The threshold is only consulted when the arena cannot serve the request. malloc_trim(0)lowered the break twice, first back past the 130 KiB chunk and then down to 0x406000, a few pages above where the program started.
The allocator’s own view: mallinfo2(3)
The same experiment without any tracer. mi.hblks is the number of chunks currently served by their own mmap; mi.arena is the bytes obtained via brk for the main arena; sbrk(0) reads the break directly.
break hblks hblkhd arena
start 0x3320c000 0 0K 0K
after malloc(120K) 0x3322e000 0 0K 136K
after malloc(130K) 0x3322e000 1 132K 136K <- break unchanged, one mmap'd chunk
after free(130K) 0x3322e000 0 0K 136K <- munmap'd on free
after malloc(130K) again 0x3326c000 0 0K 384K <- ratcheted: now from brk
Running the identical binary with the dynamic threshold disabled shows the counterfactual:
$ MALLOC_MMAP_THRESHOLD_=131072 ./backends # pins the threshold, kills the ratchet
after malloc(130K) 0x2cb2d000 1 132K 136K
after free(130K) 0x2cb2d000 0 0K 136K
after malloc(130K) again 0x2cb2d000 1 132K 136K <- mmap AGAIN, every time
And with a large M_TOP_PAD, the 130 KiB allocation never reaches sysmalloc at all, so the threshold is irrelevant:
$ MALLOC_TOP_PAD_=1048576 ./backends
after malloc(120K) 0x6075000 0 0K 1032K <- one brk bought 1032 KiB
after malloc(130K) 0x6075000 0 0K 1032K <- served from the top chunk
after free(130K) 0x6075000 0 0K 1032K
after malloc(130K) again 0x6075000 0 0K 1032K
Pinning down M_TOP_PAD’s real default
A three-line program settles the question raised earlier. Read the break, do the first heap allocation in the process, read the break again:
void *b0 = sbrk(0);
void *p = malloc(16);
void *b1 = sbrk(0);
printf("brk advanced by %ld bytes\n", (char*)b1 - (char*)b0);default : first malloc(16): brk advanced by 135168 bytes (132 KiB)
MALLOC_TOP_PAD_=0 : first malloc(16): brk advanced by 4096 bytes ( 4 KiB)
TOP_PAD_=131072 : first malloc(16): brk advanced by 135168 bytes (132 KiB)
TOP_PAD_=1048576 : first malloc(16): brk advanced by 1052672 bytes (1028 KiB)
The default is byte-identical to MALLOC_TOP_PAD_=131072, and the arithmetic checks out against sysmalloc’s formula ALIGN_UP(nb + mp_.top_pad + MINSIZE, pagesize): for malloc(16), nb is 32, so 32 + 0 + 32 → 4096 and 32 + 131072 + 32 → 135168. The effective default of M_TOP_PAD is 128 KiB, exactly as mallopt(3) and the tunables manual say, and exactly contrary to what malloc/malloc.c appears to say.
When free alone does return memory, and when it does not
The interesting question is not whether free gives memory back — it is what has to be true for it to. Allocate 20,000 chunks of 4 KiB, touch every one, then free them in different patterns and watch VmRSS from /proc/self/status:
start RSS= 1540 KiB
after 20000 x malloc(4096) + touch RSS= 82268 KiB
after freeing ALL of them RSS= 2084 KiB <- free() alone gave it all back
after malloc_trim(0) RSS= 1956 KiB
refilled, freed all but the TOP chunk RSS= 82268 KiB <- free() gave back NOTHING
malloc_trim(0) with one live chunk on top RSS= 1964 KiB <- madvise rescued it
after freeing it and trimming again RSS= 1956 KiB
Read those two blocks together. In the first, every chunk was freed, so they coalesced into one enormous top chunk; that crossed both ATTEMPT_TRIMMING_THRESHOLD and M_TRIM_THRESHOLD, systrim fired, and 80 MiB went back with no help from the program. In the second, exactly one 4 KiB allocation was left live at the highest address — and the program break could not move past it, so plain free returned zero bytes. malloc_trim(0) then recovered essentially all of it, because it does not need the free space to be at the top: it madvises whole free pages wherever they are.
That is the single most important pair of numbers in this note. “RSS did not drop after free” is not a statement about how much memory is free; it is a statement about where the live objects are.
How much fragmentation malloc_trim can and cannot fix
madvise(MADV_DONTNEED) works on whole pages, so it can only reclaim a page every one of whose bytes is free. Keep every k-th 4 KiB chunk alive and vary k:
keep every 1000-th chunk live | after alloc RSS= 82192 KiB | after free RSS= 82324 KiB | after trim RSS= 2172 KiB (live data = 80 KiB)
keep every 64-th chunk live | after alloc RSS= 82196 KiB | after free RSS= 82328 KiB | after trim RSS= 4520 KiB (live data = 1248 KiB)
keep every 8-th chunk live | after alloc RSS= 82188 KiB | after free RSS= 82320 KiB | after trim RSS= 22008 KiB (live data = 10000 KiB)
keep every 2-th chunk live | after alloc RSS= 82056 KiB | after free RSS= 82252 KiB | after trim RSS= 82252 KiB (live data = 40000 KiB)
Three readings. The after free column is flat in every row — with a live chunk pinning the top, free never returns anything, regardless of how much was released. The after trim column degrades smoothly with fragmentation. Subtracting the process’s ~1,540 KiB baseline RSS, malloc_trim gets the heap down to roughly 630 KiB of resident heap for 80 KiB of live data at 1-in-1000, about 3.0 MiB for 1.2 MiB live at 1-in-64, and about 20 MiB for 10 MiB live at 1-in-8 — a ratio that worsens from tolerable to 2× as the live objects get closer together. At 1-in-2 it recovers nothing at all, because these chunks are 4 KiB and so every single page contains a live one; there is no fully-free page anywhere for madvise to take. And the last row is the honest worst case for any madvise-based reclaim: 82 MiB resident for 40 MiB live, permanently, with no allocator bug and nothing to tune. The only fixes are structural — segregate lifetimes, use an arena/pool for the short-lived objects, or use an allocator whose size classes keep same-lifetime objects together.
Watching a secondary arena’s address space
For completeness, the mmap side of the arena story (the mechanism belongs to malloc Arenas and Thread Caching; the address-space consequence belongs here). One worker thread, then the two adjacent lines of /proc/self/maps:
7f4964000000-7f4964034000 rw-p 00000000 00:00 0 <- committed: 208 KiB, mprotect'ed R/W
7f4964034000-7f4968000000 ---p 00000000 00:00 0 <- reserved: PROT_NONE remainder
0x7f4968000000 − 0x7f4964000000 is exactly 64 MiB, and the start address is 64 MiB-aligned. alloc_new_heap gets there by mapping HEAP_MAX_SIZE << 1 (128 MiB) as PROT_NONE, carving a 64 MiB-aligned window out of it, munmapping the slack, and mprotecting only the prefix it needs; grow_heap later mprotects more of the tail as the arena grows. The ---p region is address space, not memory: it never appears in RSS. This is the whole explanation for “our 8-thread service shows 2 GB of VmSize” — check VmRSS, and ignore VmSize entirely for glibc processes.
A related detail worth knowing on 2.42 and later: alloc_new_heap and sysmalloc’s mmap-fallback path both call __set_vma_name(…, " glibc: malloc arena") and " glibc: malloc", which uses prctl(PR_SET_VMA, PR_SET_VMA_ANON_NAME, …) to label the mapping so it shows up in /proc/pid/maps as [anon: glibc: malloc arena]. That is a genuine debugging improvement — but it requires CONFIG_ANON_VMA_NAME, and it is not universal: the kernel this note was measured on reports # CONFIG_ANON_VMA_NAME is not set, so the labels are absent and the 64 MiB-alignment test above is the only way to identify arena heaps. Check your kernel’s config before relying on the names.
- “My program has a memory leak” (but it doesn’t). RSS stays high after a burst of allocations is freed because the heap wasn’t trimmed. Confirm with
malloc_trim(0)orMALLOC_TRIM_THRESHOLD_=0; if RSS then drops, it was retention, not a leak. - One high object pins the heap. A long-lived allocation at a high address blocks
sbrk-based trimming of everything below it. Allocators that place long-lived and short-lived objects together suffer this; segregating lifetimes (or using an allocator thatmadvises interior pages, like jemalloc) helps. - Explicitly setting a tunable freezes the dynamic mmap threshold. Setting
MALLOC_MMAP_THRESHOLD_(or trim/top-pad) turns off the adaptive ratchet, which can increase syscall traffic for a churning workload. MADV_FREEmakes RSS “lie.” On kernels ≥ 4.5, memory an allocator markedMADV_FREEstill counts as resident until the kernel reclaims it under pressure, so RSS can look higher than “really used.” This is expected, not a bug.- Large virtual size alarm. Seeing tens of gigabytes of
VmSizeon a many-thread program alarms operators; it is usually just the reserved-but-uncommittedHEAP_MAX_SIZEregions of secondary arenas. CheckVmRSS, notVmSize.
Failure Modes and Misunderstandings
“My program has a memory leak” — but it doesn’t. RSS stays high after a burst of allocations is freed, and everyone reaches for a leak detector. Before doing that, establish whether the memory is leaked or merely retained: call malloc_trim(0) (or run with MALLOC_TRIM_THRESHOLD_=0) and look at VmRSS again. If it falls, nothing leaked — the allocator was holding pages the default trim policy had no reason to release. The measurements above show the two shapes this takes: sometimes plain free gives everything back, sometimes it gives back nothing, and which one you get depends entirely on where the surviving objects sit. malloc_info(0, stderr) is the next tool: its per-arena <system type="current"> totals tell you which arena is holding the memory.
One high object pins the whole heap. The single most common cause of “RSS never comes down” is a long-lived allocation at a high heap address. The break cannot move past it, so systrim is powerless no matter how much free space sits below. malloc_trim can rescue whole free pages via madvise, but only pages that are entirely free — the 1-in-2 row of the fragmentation table shows the case where that recovers nothing at all. The structural fixes are to segregate lifetimes (allocate long-lived objects early, or from a separate pool) or to move to an allocator whose size classes naturally keep same-age objects together, such as jemalloc or mimalloc.
Setting one tunable freezes the adaptive logic for all of them. Any of M_TRIM_THRESHOLD, M_TOP_PAD, M_MMAP_THRESHOLD or M_MMAP_MAX — via mallopt, GLIBC_TUNABLES, or the legacy MALLOC_*_ environment variables — sets mp_.no_dyn_threshold = 1 permanently. The classic own-goal is exporting MALLOC_TRIM_THRESHOLD_=0 to shrink RSS, which also nails the mmap threshold to 128 KiB forever; a service that churns 1 MiB buffers then pays an mmap, a munmap and 256 fresh zero-fill page faults on every iteration. Enabling glibc.malloc.hugetlb=2 has the same side effect, for the same reason. If you must pin one of these, measure syscall volume as well as RSS.
MADV_FREE makes RSS look like it is lying. MADV_FREE (Linux 4.5+) marks pages reclaimable but leaves them resident until there is memory pressure, and cancels the marking if the program writes to the page first. Memory in that state still counts in VmRSS. glibc’s own trimming uses MADV_DONTNEED, which is immediate, but allocators that prefer MADV_FREE — jemalloc, and Go’s runtime in some configurations — will show RSS well above the live set on an idle machine and then shed it under pressure. That is correct behaviour, not a leak, and it is a good reason to alert on memory pressure rather than on RSS.
Large VmSize is not a problem. Tens of gigabytes of virtual size on a many-threaded glibc process is almost always the PROT_NONE tails of secondary-arena sub-heaps — 64 MiB reserved per sub-heap, a few hundred kilobytes of it committed. It consumes no physical memory and, because the reservation is PROT_NONE, not even overcommit accounting. Alert on VmRSS; ignore VmSize for glibc processes entirely. (The one case where virtual size matters is a hard RLIMIT_AS, which counts reservations — setting ulimit -v on a multithreaded glibc program will make it fail long before it uses that much real memory.)
Assuming the mmap threshold is checked on every malloc. It is checked inside sysmalloc, after the tcache, the bins and the top chunk have all failed. A request far above the threshold will be served from the heap if the heap happens to have room — demonstrated above with MALLOC_TOP_PAD_=1048576. Conversely, a request below the threshold can still end up on mmap, via sysmalloc’s fallback path, if sbrk cannot extend the break. Neither direction is a bug; the threshold is a policy about where to get new memory, not a routing rule for allocations.
Running out of mappings rather than memory. Every mmap’d chunk and every sub-heap is a VMA, counted against vm.max_map_count. The kernel’s own default is USHRT_MAX - 5 = 65,530 (include/linux/mm.h, v6.12 and still v7.1), which is why glibc caps concurrent mapped chunks at DEFAULT_MMAP_MAX = 65536. Distributions have moved: this host reports vm.max_map_count = 1048576. If you lower M_MMAP_THRESHOLD aggressively to improve return-to-OS behaviour, you are trading memory for VMAs, and the symptom of overshooting is mmap failing with ENOMEM while free -m shows plenty of memory.
brk failing for reasons unrelated to memory. RLIMIT_DATA caps the break directly, and since Linux 4.7 it also counts private writable mappings, so a low ulimit -d can break both backends at once. Separately, the stack guard gap means a growing brk must stop short of the next VMA — with ASLR, “the next VMA” can be anywhere. When this happens glibc falls back to mmap and marks the main arena non-contiguous, which silently and permanently changes its growth and trimming behaviour. It is invisible unless you trace the syscalls.
Production Notes
The knobs most often reached for in production are all read at process start-up, so they need no recompile and no code change. Each can be set either as a legacy environment variable or through GLIBC_TUNABLES=glibc.malloc.<name>=<value>; the tunable form is preferred in new deployments because it is namespaced and because ld.so --list-tunables will show you what is in effect.
MALLOC_TRIM_THRESHOLD_=<bytes>(glibc.malloc.trim_threshold) — lower it, even to0, to make glibc trim the heap top aggressively onfree, trading syscall volume for lower RSS. Remember the two gates: the trim threshold is only the second one, so setting it to 0 still will not trim on a stream of small frees until a ≥ 64 KiB chunk is freed. And setting it permanently disables the dynamic mmap threshold, so it interacts with the next knob whether you set that one or not.MALLOC_MMAP_THRESHOLD_=<bytes>(glibc.malloc.mmap_threshold) — pin the small/large cutover. Lowering it sends more allocations straight tommap, which gives much better return-to-OS behaviour at the cost of two syscalls, a VMA, and a full set of zero-fill faults per allocation; raising it keeps more in the heap. Pinning it at all disables the ratchet, which is usually a downgrade for a service with a stable allocation profile — the ratchet would have found a good value on its own within a few requests.MALLOC_TOP_PAD_=<bytes>(glibc.malloc.top_pad) — the hysteresis on heap growth, default 128 KiB. Raising it reducesbrktraffic (and therefore contention on the kernel’smmap_write_lock) at the cost of carrying more uncommitted heap; lowering it to 0 makes the heap grow one page-rounded request at a time, which is occasionally useful when hunting for the exact allocation that grew the heap. It too disables the ratchet.MALLOC_ARENA_MAX(glibc.malloc.arena_max) — the blunt instrument for the multi-arena RSS problem, covered in malloc Arenas and Thread Caching and Allocator Trade-offs and Selection. It does not disable the dynamic threshold, and is the one knob on this list that is safe to set without side effects on the others.malloc_trim(0)from inside the program — not an environment knob, but usually the more surgical fix. Calling it at a natural quiescent point (end of a request, after a GC, on an idle timer) reclaims interior free pages that no threshold setting can reach.
A frequent real-world pattern: a long-running service (web app, database) allocates a burst during a request, frees it, but RSS never drops because a few long-lived objects sit at high heap addresses pinning the break. Runtimes like Ruby address this by calling malloc_trim during garbage collection; databases like Redis often sidestep the whole class of problem by preloading jemalloc, whose per-size-class arenas and madvise-based purging return memory far more readily than glibc’s top-only trimming. The diagnostic sequence is always the same: watch VmRSS, force a malloc_trim(0) (or set MALLOC_TRIM_THRESHOLD_=0), and if RSS then falls, the “leak” was retention that the default trim policy simply wasn’t reclaiming.
See Also
- mmap brk and Address Space System Calls — the kernel-side note on these syscalls and the process address space in general; this note covers only what the allocator does with them
- ptmalloc and glibc malloc — chunks, bins, and the top chunk / wilderness that
brkgrows and trims; that note owns the chunk metadata this one only refers to - malloc Arenas and Thread Caching — arenas as units of concurrency, the tcache, and the glibc 2.43 fastbin removal;
main_arena(brk) vs. secondary arenas (mmap sub-heaps) - The Buddy Allocator — where the kernel’s pages come from once a
brkormmapregion is actually touched - Demand Paging · The Page Fault Handler — why an
mmap’d region costs nothing until first touch, and what that first touch costs - Anonymous vs File-Backed Memory — the
MAP_ANONYMOUSside of the distinction every allocation here lives on - Userspace Memory Allocation — the framing note; RSS-retention is introduced there
- Allocator Trade-offs and Selection — footprint and return-to-OS behavior as a selection axis
- Linux Memory Management MOC · Linux Userspace Runtime MOC