Heap Management and Dynamic Allocation

Every program that outlives a single function call needs special memory. Examples include a linked list that grows, a JSON document parsed at runtime, or a database buffer pool. This memory has a size and lifetime unknown at compile time. The call stack cannot hold this data. Stack frames are destroyed the instant a function returns. This happens regardless of whether some other part of the program still needs the memory. The heap exists to solve exactly this problem. It is a region of address space where blocks can be requested (`malloc`). These blocks can be used for an arbitrary and unpredictable duration. They are released (`free`) in any order. Managing that region well is a genuinely hard engineering problem. Getting it wrong is expensive. A naive allocator can waste huge fractions of memory to fragmentation. It can make every allocation call take time proportional to the number of live objects. It can corrupt the entire program if a single `free` call is duplicated or omitted. Real systems all embed a carefully engineered heap allocator. Examples include the C standard library, the JVM's native memory, database engines, and game engines running on fixed memory budgets. The design choices made there ripple through the performance and reliability of everything built on top. This article builds a working mental model of a heap allocator from first principles. It covers how free memory is tracked. It covers how allocation and deallocation requests are serviced. It explains why fragmentation is an unavoidable tension rather than a bug. It shows how real allocators (free lists, buddy systems, segregated size classes) trade off speed, memory efficiency, and simplicity. It treats manual memory management as it is practiced in C and C++. It sets up the vocabulary — blocks, headers, free lists, fragmentation. This vocabulary is needed to understand why languages with automatic memory management introduce garbage collection as the next layer of the story. By the end, you should be able to trace, byte by byte, what happens to the heap on a sequence of `malloc`/`free` calls. You should be able to explain why a first-fit allocator can waste 50% of memory in the worst case. You should be able to reason about the space and time cost of the header metadata every allocator must carry.

Key takeaways
  • The heap is a runtime-managed region of memory for objects whose size or lifetime is not known at compile time, unlike stack frames.
  • A free list — a linked list of the currently unused blocks — is the core data structure behind most manual allocators.
  • Fragmentation comes in two forms: internal (wasted space inside an allocated block) and external (free space split into unusably small pieces).
  • Allocation strategies — first-fit, best-fit, worst-fit, segregated/size-class allocators — trade off allocation speed against fragmentation.
  • Every allocated block carries metadata (size, status, sometimes boundary tags) that lets `free` merge adjacent free blocks in O(1).
  • Manual heap management is fast and predictable but unsafe (leaks, double-free, use-after-free), which motivates automatic memory management as the next evolutionary step.
The core insight

A heap allocator is fundamentally a data-structure problem — track free space compactly, find a fit quickly, and coalesce on free — and every allocator design is a specific point on the speed/fragmentation/safety tradeoff curve.

The idea

The heap is a contiguous (or logically contiguous) region of a process's address space, distinct from the stack and from static/global storage, that is managed at runtime rather than at compile time. Memory in the heap is obtained via an explicit allocation request (conceptually `alloc(size)`, realized as `malloc` in C or `new` in C++) and released via an explicit deallocation request (`free(ptr)`). Unlike a stack frame, whose lifetime is tied to a single function call and can therefore be managed with a simple pointer bump, a heap block's lifetime is arbitrary: it may be allocated in one function and freed in a completely different, much later, call — or never freed at all, in a leak. The central object an allocator manages is the block: a contiguous run of heap bytes, tagged as either allocated (in use by the program) or free (available for reuse). Because the allocator itself needs to know each block's size and status without the program telling it again, every block carries a small amount of metadata, typically a header word stored just before the pointer returned to the caller, encoding the block's size and a free/allocated bit. Some allocators also store a footer (a boundary tag) at the end of the block, duplicating the size, so that a neighboring block can be inspected in O(1) time from either side — this is what enables fast coalescing, discussed below. The free list is the data structure that ties all currently free blocks together, usually as a singly or doubly linked list threaded through the free blocks themselves (using the freed memory to store `next`/`prev` pointers, since a free block has no live data to protect). An allocation request scans this list for a block large enough to satisfy it — the scanning placement policy (first-fit, best-fit, worst-fit, next-fit) is one of the most consequential design decisions in an allocator, because it directly determines how quickly usable memory gets fragmented: split into a landscape of gaps too small to satisfy future requests, even though the total free memory would be sufficient if it were contiguous.

How it works

A heap allocator must implement two operations against a shared pool of memory: `alloc(size)` and `free(ptr)`. We build up the classic explicit free-list allocator design, which underlies the intuition (if not the exact code) of production allocators like `dlmalloc` and glibc's `ptmalloc`.

Block layout. Each block, whether free or allocated, is prefixed by a header encoding its size (including the header itself) and a status bit. A common convention packs the status bit into the low bit of the size field, since block sizes are word-aligned and their low bits are otherwise always zero: ``` [ header: size | alloc-bit ] [ payload ... ] [ optional footer: size | alloc-bit ] ```

Initialization. The allocator starts with one giant free block spanning the whole heap arena, linked into a free list of size one.

Allocation (`alloc`). Given a requested payload size, round it up to satisfy alignment (commonly 8 or 16 bytes) and add header/footer overhead, producing the true block size needed. Search the free list for a block big enough — this is the placement policy. If the chosen free block is significantly larger than needed, split it: carve off the front portion as the new allocated block, and shrink the remainder into a smaller free block that stays on the free list. If no block is found, the allocator requests more memory from the operating system (via `sbrk` or `mmap`) and retries.

Deallocation (`free`). Mark the block's header as free, and attempt to coalesce it with its immediate neighbors in memory (not in free-list order — physical order) if they are also free, merging two or three adjacent blocks into one larger free block. This is exactly what the footer/boundary-tag technique enables: to check whether the block immediately before this one (in memory) is free, the allocator reads that neighbor's footer, which sits directly before this block's header, in O(1) time — no free-list traversal required. ``` function ALLOC(requestedSize): size = ALIGN(requestedSize + HEADER_SIZE + FOOTER_SIZE) block = FIND_FIT(size) # first-fit / best-fit / etc. if block == NULL: block = REQUEST_MORE_MEMORY(size) # extend heap via sbrk/mmap if block == NULL: return NULL # out of memory REMOVE_FROM_FREE_LIST(block) if SIZE(block) - size >= MIN_BLOCK_SIZE: SPLIT(block, size) # carve block into [alloc part][remainder] INSERT_FREE_LIST(remainder) SET_HEADER(block, SIZE(block), ALLOCATED) return PAYLOAD_POINTER(block) function FREE(ptr): block = BLOCK_FROM_PAYLOAD(ptr) SET_HEADER(block, SIZE(block), FREE) prevBlock = PHYSICAL_PREV(block) # via footer of prevBlock nextBlock = PHYSICAL_NEXT(block) # via header, size lookup if prevBlock != NULL and IS_FREE(prevBlock): REMOVE_FROM_FREE_LIST(prevBlock) block = MERGE(prevBlock, block) if nextBlock != NULL and IS_FREE(nextBlock): REMOVE_FROM_FREE_LIST(nextBlock) block = MERGE(block, nextBlock) INSERT_FREE_LIST(block) function FIND_FIT(size): # FIRST-FIT variant for each block in freeList: if SIZE(block) >= size: return block return NULL ``` `FIND_FIT` is the pluggable heart of the allocator. First-fit returns the first block encountered large enough to satisfy the request; it is simple and fast on average but tends to fragment the low-address end of the heap over time. Best-fit scans the entire free list and returns the smallest block that still fits, minimizing wasted space per allocation but at the cost of a full scan, and — counterintuitively — it tends to produce many tiny, useless leftover slivers, a distinct kind of fragmentation. Worst-fit deliberately picks the largest available block, on the theory that the leftover remainder will be large enough to be useful later; in practice it usually performs worse than both. Next-fit is a variant of first-fit that resumes scanning from where the previous search left off rather than always restarting from the head of the list, spreading allocations more evenly across the heap.

Complexity & correctness

Time complexity. With an explicit free list of length kk (the number of currently free blocks), `alloc` performs a linear scan in `FIND_FIT`, costing O(k)O(k) time in the worst case for first-fit or next-fit, and O(k)O(k) always for best-fit or worst-fit, since they must examine every block to find the true minimum/maximum. `free` is O(1)O(1): updating the header, checking at most two neighbors via header/footer, splicing the free list, and merging blocks are all constant-time pointer operations, provided boundary tags are used so neighbor status is available without a search. Without footers, checking whether the physically-next block is free is still O(1) (walk forward by the current block's size), but checking the physically-previous block would require a linear scan backward or a separate index — this is precisely why the boundary-tag technique is standard: it makes coalescing genuinely constant-time in both directions. More sophisticated allocators improve the O(k)O(k) search bound. Segregated free lists (also called size-class or "bucket" allocators) maintain a separate free list per size class (e.g., 16, 32, 48, ... bytes, or exponentially: 16, 32, 64, 128, ...), so `alloc` for a common size goes directly to the matching bucket in O(1)O(1) expected time, only falling back to splitting a larger block when the exact bucket is empty. The buddy system organizes the heap as a binary hierarchy of power-of-two block sizes, giving O(log⁡(heap size))O(\log(\text{heap size})) allocation and coalescing, since merging a freed block only ever needs to check its unique "buddy" (computable by an address XOR trick) rather than an arbitrary neighbor.

Space complexity. The overhead is the header (and optional footer) per block: a constant cc bytes per allocation, independent of payload size, plus internal fragmentation from alignment rounding — an allocator with 16-byte alignment wastes on average up to 15 bytes per allocation to rounding. For nn live allocations this is O(n)O(n) total metadata overhead, which is asymptotically negligible relative to payload size for reasonably large objects, but can dominate for workloads with many tiny objects (a classic reason custom small-object allocators exist).

Correctness argument. The key invariant an allocator must preserve is: at every point in time, the set of blocks with the "free" bit set is exactly linked into the free list, no two free blocks are ever adjacent in memory, and every allocated block's returned pointer lies inside a block correctly marked allocated with a correctly recorded size. The "no two free blocks adjacent" clause is maintained by immediate coalescing on every `free` call — if it were violated, external fragmentation would be strictly worse than necessary, because two adjacent free blocks that could serve one large request would incorrectly appear as two separate, smaller opportunities. Splitting preserves the invariant because it only ever converts one free block into one allocated block plus one (still correctly free, correctly sized) remainder. Coalescing preserves it because merging updates the header/footer sizes atomically with the free-list splice, so no window exists where the metadata is inconsistent with reality (assuming single-threaded execution; multi-threaded allocators need locks or lock-free structures to preserve this atomically, a significant added complexity real allocators handle with per-thread arenas). The honest tradeoff: no placement policy is fragmentation-free in general. It is a classical result that first-fit and best-fit can both be forced into Θ(log⁡n)\Theta(\log n)-factor memory waste relative to optimal offline packing on adversarial allocation/deallocation sequences (this is a heap-allocation instance of the online bin-packing problem, which has no constant-competitive online algorithm in the worst case). Practical allocators accept this and instead engineer for the common case — small, similarly-sized, short-lived objects — via size classes, rather than trying to solve the worst case optimally.

Worked example

Consider a tiny 200-unit heap (units abstract away exact byte counts; header and footer cost 8 units each) and the request sequence: ``` alloc(40) -> P1 alloc(30) -> P2 alloc(50) -> P3 free(P2) alloc(20) -> P4 free(P1) free(P3) alloc(60) -> P5 ``` Assume first-fit placement and immediate coalescing on free.

stepoperationfree list beforeactionfree list afternotes
0—`[F0: 200]`initial heap = one free block`[F0: 200]`
1`alloc(40)`→P1`[F0: 200]`need 40+16=56; F0 fits, split`[F1: 144]`P1 occupies bytes 0–55
2`alloc(30)`→P2`[F1: 144]`need 30+16=46; split F1`[F2: 98]`P2 occupies bytes 56–101
3`alloc(50)`→P3`[F2: 98]`need 50+16=66; split F2`[F3: 32]`P3 occupies bytes 102–167; F3 too small for next request (32 < needed)
4`free(P2)``[F3: 32]`P2's neighbors: P1 (alloc), P3 (alloc) — no coalescing`[F3: 32, F2': 46]`F2' reinserted as free, isolated between two allocated blocks
5`alloc(20)`→P4`[F3: 32, F2': 46]`need 20+16=36; F3 (32) too small, first-fit tries F2' (46) — fits, split`[F3: 32, F2'': 10]`P4 reuses P2's old slot; F2'' (10 units) now too small for almost anything: fragmentation
6`free(P1)``[F3: 32, F2'': 10]`P1's next neighbor is P4 (alloc, since P4 sits where P2 was) — no coalescing (P1 is at the very start of heap, no prev)`[F1': 56, F3: 32, F2'': 10]`
7`free(P3)``[F1': 56, F3: 32, F2'': 10]`P3's neighbors: F2'' (free, before) and F3 (free, after) — coalesce all three: F2''+P3+F3`[F1': 56, Fbig: 108]`10+66+32 = 108; a single free() triggered a double merge
8`alloc(60)`→P5`[F1': 56, Fbig: 108]`need 60+16=76; F1' (56) too small, first-fit finds Fbig (108) — split`[F1': 56, Frem: 32]`P5 lands in the coalesced region

The trace exposes two crucial phenomena that a single tidy example would hide: at step 5, splitting a marginally-too-big free block leaves a 10-unit sliver that is smaller than any realistic header-plus-payload, so it sits dead in the free list until a neighbor frees and coalescing rescues it — precisely what happens at step 7. This is why real allocators track a minimum block size and refuse to split when the remainder would fall below it, instead handing out the whole block with some internal fragmentation rather than manufacturing an unusable free sliver.

Common pitfalls & traps

PitfallWhy it happensConsequence
Double free`free(p)` called twice on the same pointerCorrupts the free list (block linked into it twice), often exploitable for memory-safety attacks
Use-after-freePointer kept and dereferenced after `free`Reads/writes to memory the allocator has since reused for another object
Memory leak`alloc` called but matching `free` never reached (e.g., early return, exception)Heap grows unboundedly; long-running processes eventually exhaust memory
Forgetting alignmentHeader size computed without rounding payload to word/cache-line boundaryMisaligned pointers cause crashes on strict architectures, or silent slow paths on x86
No boundary tag, slow coalescingOnly a header, no footer, is keptMerging with the previous physical block becomes O(k)O(k) or impossible without extra bookkeeping
Best-fit assumed always betterIntuition says "least waste per call must be globally best"Produces many tiny unusable slivers — a distinct, sometimes worse, fragmentation pattern
Ignoring thread safetyFree list mutated without synchronization in a multithreaded programCorrupted free-list pointers under concurrent `alloc`/`free`, causing crashes far from the actual bug

Pioneers & history

Dynamic memory allocation as a formal subject grew out of early Lisp and operating-systems work, where researchers needed principled ways to manage a shared, mutable pool of memory across unpredictable request patterns. The theory of fragmentation, free-list search strategies, and buddy systems was substantially developed and analyzed through the 1960s–70s, turning allocator design from folklore into an area with provable worst-case bounds.

Origins · 1 minDonald Knuth and how this was discovered
DK
Donald Knuth
1938–present · Formalized and analyzed allocation strategies — first-fit, best-fit, buddy systems — in The Art of Computer Programming, establishing the rigorous complexity and fragmentation analysis that underlies modern allocator design

History
1961
— Buddy system memory allocation described (Knuth attributes early buddy-system ideas to Harry Markowitz, later formalized).
1968
— Knuth's The Art of Computer Programming, Vol. 1 formally analyzes first-fit and best-fit fragmentation behavior.
1973
— Boundary-tag coalescing technique popularized for constant-time free-block merging.
1996
— Doug Lea publishes `dlmalloc`, a widely adopted general-purpose allocator design influencing glibc's `ptmalloc`.
2000
s — Thread-caching allocators (`tcmalloc`, `jemalloc`) introduce per-thread arenas and size-class caching to eliminate lock contention.

Where this matters

Every C and C++ program, and the runtimes of most other languages (the JVM's native heap, Python's object allocator `pymalloc`, Go's allocator before its garbage collector reclaims), rests on some variant of the mechanisms described here. Operating system kernels manage their own internal heaps (the Linux kernel's `kmalloc`/slab allocator, a segregated size-class design) for kernel data structures, separate from user-space `malloc`. High-performance systems — game engines, databases, network servers — frequently replace the default general-purpose allocator with a specialized one (`jemalloc`, `tcmalloc`, custom slab or arena allocators) precisely because fragmentation and lock contention in general-purpose allocators become measurable bottlenecks under sustained load. Security research treats heap allocator internals as a primary attack surface, since corrupting free-list metadata is a classical technique for turning a memory bug into arbitrary code execution. And understanding manual heap management is the essential prerequisite for appreciating why languages adopted automatic memory management: garbage collectors must solve the same free-space bookkeeping problem, but without ever being told explicitly when an object's lifetime ends.

Ask the tutor: How would this allocator design need to change to safely support multiple threads calling `alloc`/`free` concurrently?

Why can't the stack be used for all dynamic memory instead of a heap?

The stack manages memory in strict last-in-first-out order tied to function call and return, as established in Runtime Organization: The Call Stack and Activation Records. A stack frame's memory is reclaimed automatically the instant its function returns, which is exactly wrong for an object whose lifetime must outlive the function that created it, or whose lifetime depends on runtime conditions like user input or network events. The heap removes this LIFO constraint entirely: blocks can be allocated and freed in any order, and can be handed off between functions, threads, or stored in global data structures. The cost of that flexibility is that the heap needs its own bookkeeping — headers, free lists, coalescing logic — because there is no simple stack pointer that can reclaim memory automatically.

What exactly is fragmentation, and why are there two kinds?

Fragmentation is wasted memory that cannot be used to satisfy future allocation requests, and it comes in two structurally different forms. Internal fragmentation is space wasted inside an allocated block — for example, rounding a 37-byte request up to a 40-byte aligned block wastes 3 bytes that belong to that allocation but are never used. External fragmentation is space wasted between allocated blocks — many small free blocks scattered across the heap that individually are too small to satisfy a new request, even though their total size would suffice if they were contiguous. Internal fragmentation is bounded and predictable (at most the alignment granularity per block); external fragmentation is unbounded in the worst case and depends heavily on the allocation/deallocation pattern and placement policy.

Is best-fit always better than first-fit since it wastes less space per call?

No, and this is a classic counterintuitive result in allocator design. Best-fit does minimize the leftover space for any single allocation, but that leftover is often so small it becomes practically useless — too small to satisfy almost any future request, yet too large to simply ignore. Over many allocations this produces a heap riddled with tiny, dead slivers, a distinct and sometimes worse fragmentation pattern than first-fit produces. First-fit, despite being "greedier" per call, spreads waste less systematically and is also cheaper to compute since it can stop at the first match rather than scanning the entire free list. Neither strategy dominates the other across all workloads, which is why production allocators often use hybrid or size-class-based approaches instead.

How does the buddy system improve on a plain free list?

The buddy system restricts all block sizes to powers of two and organizes the heap as an implicit binary tree: a block of size 2k2^k can only ever be split into two "buddies" of size 2k−12^{k-1}, and a freed block can only ever merge back with its unique buddy. This structure means the address of a block's buddy can be computed directly by flipping one bit in its address (an XOR with 2k−12^{k-1}), so finding whether a merge is possible takes O(1) time rather than a free-list scan. Allocation walks down the size hierarchy in O(log⁡(heap size))O(\log(\text{heap size})) steps. The cost is internal fragmentation from rounding every request up to the next power of two, which can waste up to nearly 50% of a block in the worst case, trading some memory efficiency for speed and simplicity.

What does "coalescing" actually prevent, concretely?

Coalescing merges adjacent free blocks into one larger free block immediately when a `free` call creates two free neighbors. Without it, imagine freeing two 32-byte blocks that happen to sit next to each other in memory: without coalescing they remain two separate 32-byte free-list entries, so a future request for 50 bytes fails even though 64 contiguous bytes are actually free. Coalescing keeps the free list's blocks as large as the physical memory layout allows at every point in time, which is precisely what prevents unnecessary external fragmentation from accumulating. It is why boundary tags exist: they make it possible to check, in constant time, whether the immediately preceding block in memory is also free and eligible to merge.

Why do production systems replace the default allocator with jemalloc or tcmalloc?

General-purpose allocators like classic `ptmalloc` are tuned for reasonable average-case behavior across arbitrary workloads, but real applications — web servers, databases, game engines — often have distinctive allocation patterns (many small, similarly sized, short-lived objects; heavy multithreading) that a general allocator handles suboptimally. `jemalloc` and `tcmalloc` use per-thread arenas and aggressive size-class segregation to avoid lock contention between threads and to serve common sizes in near-constant time with low fragmentation. Under sustained high-throughput load, this can mean measurably lower latency and memory footprint compared to the platform default, which is why these allocators are common defaults in production server software and are considered standard infrastructure rather than a niche optimization.

How does manual heap management relate to garbage collection?

Manual heap management, as described here, requires the programmer to explicitly call `free` at exactly the right moment: too early causes use-after-free, too late (or never) causes a leak. Garbage collection automates this decision by having the runtime itself determine when an object is no longer reachable from any live reference, eliminating an entire class of manual bugs. Underneath, however, a garbage collector still needs to solve much of the same problem this article covers — tracking free space, deciding where to place new allocations, and sometimes compacting or coalescing memory — so the free-list and fragmentation vocabulary developed for manual allocators carries over directly into understanding collector design.

Before this

Where this leads