skip to content

When an allocator frees a block, how do boundary tags let it merge with both neighbouring free blocks in constant time?

level: middleimportance: must knowfreq 58%

answer

  1. size stored twice per block
  2. footer at the block's last word
  3. neighbours found by address arithmetic
  4. the word just below the header
  5. four merge cases on release

basics

~20 s

Boundary tags repeat each block's size and free flag in a header and a footer, so a released block reaches both address neighbours by arithmetic and merges with whichever are free — no list scan.

solid answer

~40 s

Every block carries a `header` (its size plus flags) and, as a boundary tag, a `footer` at its last word repeating the same size and free bit. The next block in memory is found by adding the size to the block's own address. The previous block is found by reading the word immediately below the header — that word is the previous block's footer — and subtracting the size it reports. So on release the allocator can test both physical neighbours, unlink whichever are already free, and write one header and one footer for the merged region. The cost is independent of how many blocks the heap holds, which is why coalescing can run on every release rather than as a periodic sweep.

code

pseudocode · 19 lines
pseudocode
on release(block):
    block.header.free = true

    # step down: the word below the header is the previous block's footer
    if footer_below(block).free:
        prev = block - footer_below(block).size
        unlink(prev)
        prev.size = prev.size + block.size
        block = prev

    # step up: add our own size to reach the next header
    next = block + block.size
    if next.header.free:
        unlink(next)
        block.size = block.size + next.size

    write_header(block, block.size, free)
    write_footer(block, block.size, free)   # at block + block.size - 1 word
    push_free_list(block)

go deeper

for a junior

Recall that a block is not just its payload: a size tag sits beside it, and freeing memory can join neighbouring free space into one bigger piece.

for a middle

Explain the mechanics: header plus footer both carrying the size, the next neighbour found by addition, the previous one found by reading the word below the header, and all four merge cases.

for a senior

Show the operational consequences — a doubly linked free list so unlinking stays constant, sentinel tags at the region ends, and why a one-byte overrun corrupts a tag and crashes somewhere unrelated later.

for a principal

Frame the trade: merging on every release versus deferring it until a request fails, and whether the extra tag word per block is worth paying for a workload dominated by small blocks.

A general-purpose allocator carves blocks out of one contiguous region. When the program releases a block, the allocator's real work is not recording that the block is free — that is one bit — but deciding whether the block has just become part of a larger free region. Two adjacent free blocks that stay separate can never satisfy a request bigger than either of them, so merging on release is what keeps a long-lived heap able to answer large requests at all. ## Neighbour means address neighbour The neighbours that matter are **physical**: the block that ends exactly where this one begins, and the block that begins exactly where this one ends. That is a different relation from the free list. The free list links free blocks in insertion order, or size order, or whatever order the design chose; two blocks adjacent on the list may sit at opposite ends of the region, and two blocks adjacent in memory may be nowhere near each other on the list. Walking the list therefore cannot tell you who your neighbours are, and walking it anyway would make release cost time proportional to the number of free blocks. Going **forward** is easy, because every block already needs a header. One word holds the block's size and a couple of flag bits (allocated or free; often also whether the block below is free). Add that size to the block's own address and you land precisely on the next block's header. Going **backward** is the hard direction. Standing on a header there is nothing in it that says how far down the previous header lies — the previous block may be any size at all. ## The boundary tag Knuth's answer is to repeat the size at the **end** of the block as well as at the start: - the **header** sits immediately before the payload and holds `size` plus flags; - the **footer** — the boundary tag — occupies the block's last word and repeats the same `size` and free bit; - therefore the word immediately *below* any header is, by construction, the previous block's footer. Read that word, subtract the size it reports from the current block's address, and you are standing on the previous block's header. The step is two memory reads and a subtraction, regardless of heap size or block count. ## The release path, step by step 1. Mark the block free in its own header. 2. Read the word below the header. If the previous block is free, unlink it from the free list and extend the region being formed downwards to its header. 3. Compute the following block's address by adding the (possibly already extended) size. If that block is free, unlink it too and extend the region upwards. 4. Write the surviving header at the region's lowest address and the surviving footer at its highest, both carrying the summed size, and push the single resulting block onto the free list. The swallowed blocks' interior tags are not erased. They are simply never read again, because after step 4 no header points at them. | previous neighbour | next neighbour | result of the release | |---|---|---| | in use | in use | the block stays alone and joins the free list | | free | in use | one block; the previous block's header survives | | in use | free | one block; this block's header survives | | free | free | one block spanning all three; the previous header survives | ## What it costs and how the cost is trimmed - **Space.** A footer doubles the per-block tag. Many designs keep the footer only in free blocks and carry a *previous-block-is-free* bit in each header instead, so an allocated block pays one word and a free block — whose space is idle anyway — pays two. - **Unlinking must also be constant time.** Extending a region downwards means removing a block from the middle of the free list, so the list is doubly linked; a singly linked list would force a scan and undo the whole benefit. - **Immediate versus deferred merging.** Merging on every release keeps the heap tidy but costs work on a block that may be re-requested immediately. Some designs defer merging until a request fails, trading a tidier steady state for less work on hot release paths. ## Where it goes wrong A write one byte past the end of a payload lands on a footer, and a write just before a payload lands on a header. Either corrupts a size, and the next walk steps into the middle of a block and treats arbitrary bytes as a tag. The crash then appears far away from the code that caused it, which is why allocators with tag checking are worth their cost during development. The first and last blocks of the region also need sentinel tags marked permanently in use, or the walk steps outside the region entirely.

  • Why can an allocated block usually drop its footer, and what replaces it?
    A footer exists only so the block above can step backwards. If each header also carries a previous-block-is-free bit, the block above already knows whether stepping back is worth doing, and it only ever steps back into a free block. So the footer is written only when a block is freed, using space that is idle anyway, and allocated blocks pay one word of tag instead of two.
  • What has to be true of the free-list structure for coalescing to stay constant time?
    Merging removes a neighbour from the middle of the free list, not from its head, so the list must support removal in constant time — that means doubly linked, with the links stored inside the free block's own payload space. With a singly linked list the allocator would have to scan for the predecessor, making each release cost time proportional to the number of free blocks.

Boxes on a shelf with their length printed on both the front and the back label. Standing at one box you can read the label facing you to step forward, and read the back label of the box behind to step back — without walking the shelf from the end.

saying these in an interview costs you the question

  • Thinks the allocator scans the free list to find a block's neighbours
  • Believes a released block can only ever merge forwards
  • Assumes the header alone is enough to locate the previous block
  • Claims coalescing costs time proportional to the heap size
  • Confuses free-list order with address order in memory
  • Thinks merging rewrites or clears the swallowed blocks' interior tags