Memory Allocator

What it is

The memory allocator is the layer between the application and the operating system that manages heap memory. malloc(n) / new T finds a free block of at least n bytes; free(p) / delete p returns it. The OS only hands out memory in large units — pages, via brk/sbrk (grow the heap) or mmap (map anonymous pages) — and each of those is a system call costing ~1 µs or more. The allocator requests memory from the OS in large chunks and carves them into small blocks in user space, so most malloc calls cost tens of nanoseconds and no system call.

graph TD app["Application: new / malloc"] --> alloc["Allocator (user space)<br/>size classes, free lists, per-thread caches"] alloc -->|"rarely: need more memory"| os["Kernel: mmap / brk<br/>(page-granular)"] os --> vm["Virtual memory → physical pages<br/>on first touch (page fault)"]

Memory obtained from the OS is only virtual until first written — see Virtual Memory and Page Faults.

Size classes and free lists

General-purpose allocators round each request up to a size class (for example 16, 32, 48, 64, 80, ... bytes, then coarser steps) and keep a free list per class. Allocating pops the head of the list; freeing pushes onto it. Both are O(1).

Internal fragmentation is the waste from rounding: a 33-byte request in a 48-byte class wastes 15 bytes. It is bounded and predictable.

External fragmentation is free memory that exists but is split into pieces that cannot satisfy a request, or spread across many partially used pages that cannot be returned to the OS. A long-running server that allocated 10 GB at peak, then freed 90% of the objects scattered across all pages, may still hold most of the 10 GB — every page keeps one live object. This is why resident memory (RSS) can stay high after the data shrinks, a common surprise in database processes.

Allocators you will meet

Allocator Used by Design highlights
glibc malloc (ptmalloc2) Default on Linux Multiple "arenas" to reduce lock contention; can fragment badly under multi-threaded churn
jemalloc FreeBSD, Redis, RocksDB deployments, many databases Per-thread caches, size-class slabs, strong anti-fragmentation, detailed stats
tcmalloc (Google) Google services, available for any native program Per-thread/per-CPU caches, central free lists, page heap
mimalloc (Microsoft) Various Free-list sharding per page, very fast small allocations
Go runtime allocator Go programs tcmalloc-inspired three tiers; works with the garbage collector

Switching a native (C or C++) storage engine such as RocksDB from glibc malloc to jemalloc often reduces memory usage noticeably for workloads with many threads and variable-sized allocations; RocksDB documentation recommends it. It can be done without code changes by linking against it or with LD_PRELOAD=libjemalloc.so.

Thread-local caching: the tcmalloc / Go design

graph TD alloc["Allocation request"] --> mcache["Per-thread / per-P cache<br/>(no lock, fastest)"] mcache -->|"empty for this size class"| mcentral["Central free list per size class<br/>(locked, refills caches in batches)"] mcentral -->|"empty"| mheap["Page heap<br/>(large spans from the OS;<br/>serves big allocations directly)"]

The fast path touches only memory owned by the current thread (or CPU or, in Go, the current P), so it needs no lock. Batched refills and returns amortize the locking on the central lists. Go's allocator uses exactly these three tiers — mcache per P, mcentral per size class, mheap for spans and for allocations over 32 KB.

Arena allocation: what storage engines do on top

For data with a shared lifetime, a general-purpose allocator does unnecessary work: each object is tracked and freed individually even though all of them die together. An arena (also called a region or bump allocator) hands out memory by advancing a pointer through a large block, and frees everything at once when the arena is destroyed.

// Arena hands out byte ranges from large chunks. Entries are referenced
// by (chunk, offset), so whatever indexes them contains no pointers.
type Arena struct {
    chunks [][]byte
    cur    []byte
}

const chunkSize = 1 << 20

func (a *Arena) Alloc(n int) (chunk, off int) {
    if len(a.cur)+n > cap(a.cur) {
        a.cur = make([]byte, 0, max(chunkSize, n)) // one allocation per MiB
        a.chunks = append(a.chunks, a.cur)
    }
    off = len(a.cur)
    a.cur = a.cur[:off+n]
    a.chunks[len(a.chunks)-1] = a.cur
    return len(a.chunks) - 1, off
}

func (a *Arena) Bytes(chunk, off, n int) []byte { return a.chunks[chunk][off : off+n] }

// No Free: drop the whole Arena when its contents die together.

A memtable is the textbook case: millions of entries are inserted, none are freed individually, and the whole memtable is discarded after it is flushed to an SSTable. Measured in Go (Go 1.26.4, Apple M1 Pro): 2 million 64-byte entries, kept live, allocated either with one make each (references kept in a [][]byte) or from the arena above (references kept as pointer-free (chunk, offset) pairs):

Per allocation Heap objects One full GC with the entries live
make([]byte, 64) per entry 20–27 ns 2,000,176 5.7–6.2 ms
Arena + offsets 4.6–5.0 ns 304 0.14–0.16 ms

Allocating from an arena:

  • costs a few instructions instead of a call into the allocator,
  • packs entries contiguously (better cache behavior),
  • makes memory usage trivially measurable (the arena's size decides when the memtable is full),
  • and, in a garbage-collected language, removes the entries from the collector's work: a []byte chunk contains no pointers, so marking it costs nothing, and 2 million objects become 2 heap objects per MiB — a 40x shorter collection in the measurement above.

The price is that nothing in the arena can be freed individually, and an arena that outlives its purpose holds all of its memory. LevelDB's memtable uses exactly this design; Pebble's arenaskl is the Go version. Why the engine in this handbook does not (yet) is in Memtable and Skip List.

Garbage-collected runtimes: Go

Go's GC is a concurrent tricolor mark-and-sweep: white (not yet seen), gray (seen, children not scanned), black (seen and scanned). When marking finishes, remaining white objects are unreachable and swept. Write barriers keep marking correct while the program keeps running; stop-the-world pauses are typically well under a millisecond. A GC cycle starts when the heap grows by GOGC percent (default 100) over the live heap after the last collection; GOMEMLIMIT sets a soft total limit. Go storage engines (Pebble, Badger) still use arena-like manual memory for block caches and memtables, to keep large data off the GC's scanning path.

Real-world usage: ValkeyDB uses sync.Pool for frequently-allocated command parser buffers to reduce GC pressure.

Measuring allocation

Tool Shows
go test -benchmem, testing.AllocsPerRun Allocations per operation
pprof heap profile (alloc_space, inuse_space) Allocation sites and bytes; what is live now
runtime/metrics (/gc/heap/..., /memory/classes/...) Live heap, heap goal, fragmentation, memory returned to the OS
GODEBUG=gctrace=1 Live heap and GC cost per cycle
perf record showing runtime.mallocgc frames CPU time spent in the allocator, including from cgo

Details in Reducing Allocations and GC Pressure.

Common pitfall

Allocating many small objects in a hot path costs more than the allocator's own time. In Go every live allocation is also something the GC must mark, and allocation rate decides how often it runs, so a loop that allocates a small struct per iteration can show up as most of the CPU profile inside runtime.mallocgc, gcAssistAlloc, and GC background workers. The fix is to make allocation proportional to batches, not to items: reuse buffers (sync.Pool, or a buffer kept across iterations), store values instead of pointers, or allocate items with a shared lifetime from an arena.