Almost every C program calls malloc(), yet very few developers can say what happens between the call and the pointer that comes back. This article explains how malloc works internally in glibc’s allocator (ptmalloc2, derived from Doug Lea’s dlmalloc), and it does so in a way you can verify yourself: every data structure described here is observable with a short C program, strace, and a GDB session.
The short version
When you call malloc(n) in a glibc program:
- The size you asked for is rounded up and an 8-byte header is added, producing a chunk.
- glibc looks for a reusable free chunk in a cascade of free lists: tcache, then fastbins, then the unsorted bin, then small bins and large bins.
- If nothing fits, it carves the memory off the top chunk, the wilderness block at the end of the heap.
- If the top chunk is too small, glibc asks the kernel for more memory with brk (main arena) or mmap (thread arenas, or any request above the mmap threshold).
- It returns a pointer to the first byte after the chunk header.
That is the whole algorithm in five lines. The rest of this post is about the details that actually matter when you are debugging fragmentation, memory that never returns to the OS, or a heap corruption crash.

Layer by layer: from your call to the kernel
It helps to keep the layering straight, because “malloc” is used loosely to mean three different things:
- The API layer:
malloc,calloc,realloc,free,posix_memalign. This is what your code sees. - The allocator layer: ptmalloc inside glibc. It owns chunks, bins, arenas and the top chunk. This is where the real work happens.
- The kernel layer:
brk/sbrkandmmap. The kernel only knows about page-aligned virtual memory regions, it has no idea what a chunk is.
A crucial consequence: calling free() almost never calls the kernel. It hands a chunk back to the allocator layer, not to the operating system. That single fact explains most “my program’s RSS never goes down” tickets.
The chunk: glibc’s fundamental unit of memory
Everything in ptmalloc is a chunk. The in-memory structure is declared in malloc/malloc.c roughly like this:
struct malloc_chunk {
INTERNAL_SIZE_T mchunk_prev_size; /* size of previous chunk, if it is free */
INTERNAL_SIZE_T mchunk_size; /* size of this chunk, low 3 bits are flags */
struct malloc_chunk* fd; /* double links, used only when free */
struct malloc_chunk* bk;
struct malloc_chunk* fd_nextsize; /* large bins only */
struct malloc_chunk* bk_nextsize;
};
The header and its flag bits
On a 64-bit Linux system, INTERNAL_SIZE_T is 8 bytes and the malloc alignment is 16 bytes. Because chunk sizes are always a multiple of 16, the low 4 bits of the size field are free, and glibc uses the bottom three as flags:
| Bit | Name | Meaning when set |
|---|---|---|
| 0x1 | PREV_INUSE | The physically previous chunk is allocated, so prev_size is not valid |
| 0x2 | IS_MMAPPED | Chunk came from its own mmap call and will be munmaped on free |
| 0x4 | NON_MAIN_ARENA | Chunk belongs to a thread arena, not the main arena |
The pointer you receive from malloc points to the fd field, that is, 16 bytes after the start of the chunk structure but only 8 bytes after the size field. The prev_size field of the next chunk is re-used as payload space while a chunk is in use. This overlap is why the usable size is chunk_size - 8 and not chunk_size - 16. daniweb.com makes the same point with more data.
Rounding rules you can verify
The request-to-chunk conversion is max(MINSIZE, align16(req + 8)) with MINSIZE = 32 on 64-bit. That produces this table:
| malloc(n) | Chunk size | Size field (with PREV_INUSE) | malloc_usable_size() |
|---|---|---|---|
| 0 to 24 | 32 | 0x21 | 24 |
| 25 to 40 | 48 | 0x31 | 40 |
| 41 to 56 | 64 | 0x41 | 56 |
| 1000 | 1008 | 0x3f1 | 1000 |
Note the practical consequence: malloc(1) costs 32 bytes of heap. Allocating a million tiny nodes costs at least 32 MB, before any fragmentation.
Experiment 1: read the chunk header yourself
/* chunk.c : gcc -g -O0 chunk.c -o chunk */
#include <stdio.h>
#include <stdlib.h>
#include <malloc.h>
int main(void)
{
char *a = malloc(24);
char *b = malloc(25);
printf("a = %p\n", (void *)a);
printf("b = %p\n", (void *)b);
printf("delta = %ld\n", (long)(b - a));
printf("hdr(a) = 0x%zx\n", *((size_t *)a - 1));
printf("hdr(b) = 0x%zx\n", *((size_t *)b - 1));
printf("usable a = %zu, usable b = %zu\n",
malloc_usable_size(a), malloc_usable_size(b));
free(a);
char *c = malloc(24);
printf("c = %p (same as a? %s)\n", (void *)c, c == a ? "yes" : "no");
return 0;
}
Typical output on x86-64: delta is 32, hdr(a) is 0x21, hdr(b) is 0x31, and c == a. That last line is the tcache doing its job, and it is the simplest possible demonstration of LIFO reuse. Anyone digging further should read Am I understanding malloc() correctly.

Where freed memory goes: tcache, fastbins and the rest
The core of how malloc works internally is the set of free lists. glibc does not keep one list, it keeps several, each tuned for a different size class and a different speed/quality tradeoff.
tcache (thread-local cache)
Added in glibc 2.26 and enabled by default ever since, the tcache is a per-thread array of 64 singly linked lists, holding up to 7 chunks each by default, for chunk sizes from 32 up to 1040 bytes (requests up to 1032 bytes). It requires no locking at all, which is why the fast path of malloc on a modern glibc is only a few dozen instructions. Chunks in the tcache are not coalesced and their PREV_INUSE status stays as if they were still allocated, so neighbours cannot merge with them.
fastbins
Ten singly linked LIFO lists for chunks up to 128 bytes (global_max_fast). They are per-arena, protected by the arena lock, and like the tcache they skip coalescing to stay fast. Fastbin chunks are only merged later, during malloc_consolidate(), which is triggered when a large request cannot be satisfied or when a chunk of 64 KB or more is freed.
The unsorted bin
A single doubly linked list that acts as a staging area. Freed chunks that are too big for tcache/fastbins land here first. On the next allocation, glibc walks the unsorted bin, and each chunk is either returned immediately (if it matches the request exactly) or filed into the proper small or large bin. This “sort lazily, one pass at a time” design is a deliberate amortization trick.
Small bins and large bins
There are 62 small bins covering chunk sizes below 1024 bytes, each holding exactly one size class, 16 bytes apart. Insert and remove are O(1). There are 63 large bins for everything above that, each holding a range of sizes kept sorted in decreasing order using the extra fd_nextsize/bk_nextsize pointers, so a best-fit search is fast.
Top chunk and last remainder
The top chunk is the free block at the very end of the arena’s heap, sometimes called the wilderness. It is never in any bin. When all bins fail, glibc splits the top chunk. When the top chunk itself is too small, the arena grows. The last remainder is the leftover of the most recently split chunk, kept aside to improve locality for sequences of similar small allocations.
| Structure | Count (64-bit) | Chunk sizes | Linkage | Lock needed | Coalescing |
|---|---|---|---|---|---|
| tcache | 64 per thread | 32 to 1040 | Singly linked, LIFO, max 7 | No | No |
| fastbins | 10 per arena | 32 to 128 | Singly linked, LIFO | Yes | Deferred |
| unsorted bin | 1 per arena | Any | Doubly linked | Yes | Yes |
| small bins | 62 per arena | 32 to 1008 | Doubly linked, FIFO | Yes | Yes |
| large bins | 63 per arena | 1024 and up | Doubly linked, size sorted | Yes | Yes |
| top chunk | 1 per arena | Whatever is left | None | Yes | Grows and shrinks |
The allocation path, step by step
Here is the actual order of operations inside _int_malloc(), simplified but faithful:
- Size conversion. Convert the request into a chunk size, reject absurd sizes (integer overflow checks).
- mmap shortcut. If the chunk size is at or above
mmap_threshold(128 KB by default), callmmapdirectly and return. No bin is consulted. - tcache hit. If the size class has a cached chunk, unlink it and return. No lock taken.
- fastbin hit. If the size fits a fastbin and it is non-empty, pop it. While here, glibc opportunistically refills the tcache from the same fastbin.
- small bin hit. Exact-size small bin, pop from the tail. Again the tcache may be pre-filled.
- consolidate. For larger requests, run
malloc_consolidate()to merge all fastbin chunks into the unsorted bin. - unsorted bin scan. Walk the unsorted bin: return an exact match, use the last remainder if applicable, otherwise sort each chunk into its small or large bin.
- best fit in large bins. Search the smallest bin that can hold the request, split the chunk and put the remainder back into the unsorted bin.
- binmap scan. Use the arena’s bitmap of non-empty bins to jump to the next larger populated bin.
- top chunk split. Carve the request out of the wilderness.
- grow the arena. Call
sysmalloc(), which usesbrkfor the main arena ormmapfor thread arenas, then retry. If that fails too, try another arena, then returnNULLand seterrnotoENOMEM.

The free path, step by step
- Compute the chunk address from the user pointer:
chunk = ptr - 16. - If
IS_MMAPPEDis set, callmunmapand stop. This is the only case wherefree()immediately returns memory to the kernel. - If the size class fits the tcache and the bin is not full, push the chunk there and stop. A key field is written into the chunk to detect double frees cheaply.
- If the size fits a fastbin, push it onto the fastbin, without touching neighbours.
- Otherwise, check the
PREV_INUSEbit of the previous chunk and the in-use state of the next chunk, and coalesce with any free neighbour. - Place the merged chunk in the unsorted bin, unless it is adjacent to the top chunk, in which case it is absorbed by the top chunk.
- If the top chunk has grown beyond
trim_threshold(128 KB by default), callsystrim(), which shrinks the break withbrkor punches a hole withmadvise(MADV_DONTNEED)in thread heaps.
Since glibc 2.32, pointers stored in tcache and fastbin free lists are mangled (“safe linking”, ptr ^ (address >> 12)), so a raw pointer dump of a free chunk will not show a plain address. Keep that in mind when inspecting the heap in GDB.
brk versus mmap: when malloc talks to the kernel
This is the part most tutorials get half right. glibc uses both mechanisms, and the choice is dynamic.
- brk / sbrk extends the data segment of the process, producing the region labelled
[heap]in/proc/self/maps. It is used by the main arena only, and it can only grow and shrink at one end, like a stack. - mmap is used for (a) any single request at or above the mmap threshold, (b) creating heaps for thread arenas, and (c) as a fallback when
brkfails, which happens routinely once another mapping sits right above the heap.
The dynamic mmap threshold
The default threshold is 128 KB, but it is adaptive. When a chunk that was allocated with mmap is freed, glibc raises the threshold to that chunk’s size (capped at 32 MB on 64-bit) and raises the trim threshold to twice that. The rationale: if a program frees a 1 MB mmapped block, it will probably allocate another one soon, and recycling it from the heap is much cheaper than a pair of mmap/munmap syscalls. Setting M_MMAP_THRESHOLD explicitly with mallopt() disables this adaptation.
Experiment 2: watch the syscalls
/* growth.c : gcc -O0 growth.c -o growth */
#include <stdlib.h>
#include <stdio.h>
int main(void)
{
for (int i = 0; i < 2000; i++)
malloc(1024); /* many small chunks -> brk grows in steps */
void *big = malloc(200 * 1024); /* above 128 KB -> its own mmap */
free(big); /* munmap, and threshold moves up */
void *big2 = malloc(200 * 1024); /* now served from the heap, no syscall */
free(big2);
printf("%p %p\n", big, big2);
return 0;
}
$ strace -e trace=brk,mmap,munmap ./growth
You will see a burst of brk calls growing the heap in increments driven by top_pad (132 KB by default), then one mmap of roughly 204 KB, one munmap, and then no syscall at all for the second 200 KB allocation. That single trace demonstrates the adaptive threshold better than any diagram.
Arenas: why multithreaded programs behave differently
A single global lock would serialize every allocation, so ptmalloc splits the heap into arenas, each with its own bins, its own top chunk and its own mutex.
- The main arena uses
brkand itsmalloc_statelives in glibc’s data segment as the symbolmain_arena. - Thread arenas are created on demand when a thread finds the arenas it tries contended. Each one gets a 64 MB aligned mmap region (
HEAP_MAX_SIZE) of which only a small part is committed. The alignment is a trick: given any chunk address, glibc can find its arena by masking the low bits. - The number of arenas is capped at 8 times the number of CPU cores on 64-bit (2 times on 32-bit), unless you override
glibc.malloc.arena_max. - A thread remembers its arena in a thread-local variable and only switches if it repeatedly fails to grab the lock.
This is the usual explanation for a puzzling observation: a threaded server that allocates a few megabytes shows hundreds of megabytes of virtual memory. Those are mostly reserved, untouched arena mappings. Check RSS, not VSZ, and set MALLOC_ARENA_MAX if per-arena fragmentation is genuinely hurting you.
Experiment 3: one thread, two heaps
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
void *worker(void *arg)
{
printf("thread chunk: %p\n", malloc(64));
return NULL;
}
int main(void)
{
printf("main chunk: %p\n", malloc(64));
pthread_t t; pthread_create(&t, NULL, worker, NULL); pthread_join(t, NULL);
system("grep -E 'heap|rw-p' /proc/self/maps | head");
return 0;
}
The two pointers will be far apart, and the thread’s chunk will sit inside an anonymous mapping rather than in [heap]. Inspect its size field in GDB and you will see the NON_MAIN_ARENA bit (0x4) set.

A practical GDB workflow for heap inspection
You do not need a plugin to get started. With glibc debug symbols installed:
(gdb) break main
(gdb) run
(gdb) p main_arena # the whole malloc_state
(gdb) p main_arena.top # current top chunk pointer
(gdb) p main_arena.fastbinsY # the 10 fastbin heads
(gdb) x/8gx (char *)ptr - 16 # header + payload of a chunk
(gdb) p *(size_t *)((char *)ptr - 8) # size field with flags
(gdb) call (void) malloc_stats() # arena summary on stderr
(gdb) info proc mappings # is this address in [heap] or an mmap?
Two helpers worth knowing:
malloc_usable_size(p)tells you the real capacity of a chunk, which is often more than you asked for.mallinfo2()(glibc 2.33 and later) gives 64-bit-safe counters for arena size, free chunks, mmapped bytes and top chunk size.
If you want a ready-made view of the bins, the debugger extensions pwndbg and GEF provide heap chunks, bins, heap bins tcache and similar commands. They are the fastest way to see the whole picture, but do the manual pointer arithmetic at least once. It is what makes the layout stick.
Internals mapped to behaviors you have already seen
| Observed behavior | Internal cause |
|---|---|
| RSS never drops after freeing millions of small objects | Freed chunks go to bins, and the heap can only be trimmed from the top. One live chunk near the top pins everything below it. |
| Freeing a 1 MB buffer does drop RSS immediately | It was an IS_MMAPPED chunk, so free() called munmap. |
| The same address comes back from consecutive malloc/free pairs | tcache LIFO reuse. |
| Writing 1 byte past a buffer sometimes crashes much later | You overwrote the next chunk’s size field, and the corruption is only detected during a later coalesce or unlink. |
| “free(): double free detected in tcache 2” | The per-chunk tcache key matched, so glibc walked the bin and found the chunk already present. |
| Allocation throughput collapses with many threads | Arena contention, or arena_max set too low for the core count. |

Tuning knobs (and when to touch them)
glibc exposes tunables through the GLIBC_TUNABLES environment variable and, for some of them, through mallopt():
| Tunable | Default (64-bit) | Effect |
|---|---|---|
| glibc.malloc.tcache_count | 7 | Chunks cached per size class per thread. Set to 0 to study the fastbin path. |
| glibc.malloc.tcache_max | 1032 | Largest request eligible for the tcache. |
| glibc.malloc.mmap_threshold | 131072, adaptive | Setting it freezes the adaptive behavior. |
| glibc.malloc.trim_threshold | 131072, adaptive | How much free space at the top before the heap is shrunk. |
| glibc.malloc.top_pad | 131072 | Extra bytes requested from the kernel on every heap growth. |
| glibc.malloc.arena_max | 8 x cores | Hard cap on arenas. Lower it to reduce per-arena fragmentation. |
| glibc.malloc.perturb | 0 | Fills allocated and freed memory with a byte pattern. Excellent for catching use-after-free. |
$ GLIBC_TUNABLES=glibc.malloc.tcache_count=0:glibc.malloc.perturb=165 ./myprog
Measure before and after. Most real wins come from allocating less, not from tuning the allocator.
Common misconceptions
- “malloc calls sbrk”: only sometimes. Thread arenas and large requests use
mmap, and the modern main arena falls back tommapwhenbrkcannot grow. - “free returns memory to the OS”: only for mmapped chunks, or when the top chunk exceeds the trim threshold.
- “malloc stores the size just before the pointer”: it stores the chunk size including header and alignment padding, plus three flag bits. Never treat it as your requested size, use
malloc_usable_size(). - “the heap is one contiguous region”: the main arena is, thread arenas are separate mappings, and large chunks live wherever
mmapput them. - “all malloc implementations work this way”: jemalloc, tcmalloc, mimalloc and musl’s allocator use size-class slabs or radix structures with very different tradeoffs. Everything above is specific to glibc ptmalloc.
FAQ
How does malloc know how much memory to free?
It reads the size field stored in the 8 bytes immediately before the pointer you pass to free(), masks off the three flag bits, and obtains the chunk size. This is also why passing a pointer that did not come from malloc corrupts the heap: glibc will interpret whatever bytes sit there as a chunk header.
What is the difference between tcache and fastbins?
Both are singly linked LIFO lists of small chunks that skip coalescing. The tcache is per thread and lock free, limited to 7 chunks per size class and 64 size classes. Fastbins are per arena and lock protected, unlimited in length, and only cover chunks up to 128 bytes. The tcache is checked first.
When does malloc use mmap instead of brk?
When the chunk size reaches the current mmap threshold (128 KB by default, adaptive up to 32 MB), when a thread arena needs a new heap, and whenever brk cannot extend the main heap. Chunks obtained that way carry the IS_MMAPPED flag and are unmapped on free().
Why does malloc(1) reserve 32 bytes?
Alignment plus bookkeeping. On 64-bit the alignment is 16 bytes, the header is 8 bytes, and a free chunk must be able to hold two list pointers, so the minimum chunk size is 32 bytes. Twenty-four of those bytes are usable.
Is malloc thread safe?
Yes. Concurrency is handled by the tcache (no lock needed on the fast path) and by per-arena mutexes. It is not async-signal-safe, so never call malloc or free from a signal handler.
How can I see the heap layout of a running process?
Attach with GDB and inspect main_arena plus raw chunk headers, call malloc_stats() or malloc_info(0, stdout) from inside the process, and read /proc/<pid>/maps and /proc/<pid>/smaps to separate [heap] from arena and large-chunk mappings. A comparable breakdown sits on edu.ng.
Should I replace glibc malloc?
Only after measuring. Allocator-heavy multithreaded workloads often gain from jemalloc or tcmalloc, mostly through better size-class design and lower fragmentation. Single-threaded or allocation-light programs rarely see a difference, and ptmalloc’s per-thread cache closed much of the historical gap.
Where to go next
Read malloc/malloc.c in the glibc sources: it is long, but the comment block at the top is one of the best pieces of allocator documentation in existence. Then re-run the three experiments above while changing one tunable at a time. Understanding how malloc works internally is not academic trivia, it is what turns “memory leak somewhere” into a reproducible, bounded bug report.