Articles

How malloc Works Internally in glibc: Arenas, Bins, and the Top Chunk Explained

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:

  1. The size you asked for is rounded up and an 8-byte header is added, producing a chunk.
  2. 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.
  3. If nothing fits, it carves the memory off the top chunk, the wilderness block at the end of the heap.
  4. 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).
  5. 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.

memory heap diagram

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/sbrk and mmap. 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.

memory heap diagram

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:

  1. Size conversion. Convert the request into a chunk size, reject absurd sizes (integer overflow checks).
  2. mmap shortcut. If the chunk size is at or above mmap_threshold (128 KB by default), call mmap directly and return. No bin is consulted.
  3. tcache hit. If the size class has a cached chunk, unlink it and return. No lock taken.
  4. 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.
  5. small bin hit. Exact-size small bin, pop from the tail. Again the tcache may be pre-filled.
  6. consolidate. For larger requests, run malloc_consolidate() to merge all fastbin chunks into the unsorted bin.
  7. 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.
  8. 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.
  9. binmap scan. Use the arena’s bitmap of non-empty bins to jump to the next larger populated bin.
  10. top chunk split. Carve the request out of the wilderness.
  11. grow the arena. Call sysmalloc(), which uses brk for the main arena or mmap for thread arenas, then retry. If that fails too, try another arena, then return NULL and set errno to ENOMEM.
memory heap diagram

The free path, step by step

  1. Compute the chunk address from the user pointer: chunk = ptr - 16.
  2. If IS_MMAPPED is set, call munmap and stop. This is the only case where free() immediately returns memory to the kernel.
  3. 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.
  4. If the size fits a fastbin, push it onto the fastbin, without touching neighbours.
  5. Otherwise, check the PREV_INUSE bit of the previous chunk and the in-use state of the next chunk, and coalesce with any free neighbour.
  6. 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.
  7. If the top chunk has grown beyond trim_threshold (128 KB by default), call systrim(), which shrinks the break with brk or punches a hole with madvise(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 brk fails, 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 brk and its malloc_state lives in glibc’s data segment as the symbol main_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.

memory heap diagram

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.
memory heap diagram

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 to mmap when brk cannot 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 mmap put 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.

Latest Posts

No Posts Found!

Banner of the Day

NewsLetter

Do not miss our news
Sign up and receive the latest news of our company
Newsletter

Contact Info
Copyright © 2022 I-4 Linux. All Rights Reserved.