malloc / free Lifecycles¶
The libc allocator interface is exactly four functions — malloc, calloc, realloc, free — plus aligned_alloc since C11. Every C program that touches the heap goes through this API, and every long-running C program eventually hits its edge cases. Learn the contract, not just the syntax; the contract is where the bugs live.
The Four (Really Five) Functions¶
Function |
What it does |
Cost hint |
|---|---|---|
|
Returns pointer to |
Typically 50-200 ns for small sizes on glibc/tcmalloc. |
|
Returns pointer to |
Same as |
|
Grows/shrinks the block at |
O(n) worst case (copy). Amortized O(1) if you double sizes. |
|
Releases block. |
~40-100 ns typical. |
|
C11. Like |
Same as |
One rule ties them all together: you free exactly what malloc/calloc/realloc/aligned_alloc returned, exactly once, and only that. You cannot free an interior pointer. You cannot free a stack address. You cannot free the same pointer twice. Every violation is undefined behavior, and on glibc most of them get caught with a diagnostic (“double free or corruption”, “free(): invalid pointer”) — sometimes minutes after the actual bug, in a completely unrelated function.
Idioms Worth Memorizing¶
Allocate then check¶
T *p = malloc(n * sizeof *p);
if (!p) { /* handle OOM: return, log, or die */ }
On Linux with overcommit, malloc rarely returns NULL for small allocations — the OOM killer arrives later. But you still check, because (a) large allocations can fail immediately, (b) your code might run under strict overcommit or ulimits, and (c) NULL check is one line and unbraces you from an entire class of bugs.
Free then null¶
free(p);
p = NULL;
After free(p), p still contains the old address — which is now invalid. Setting it to NULL means the next stray use crashes cleanly instead of corrupting a re-used heap block. Some codebases wrap this in a macro:
#define FREE(p) do { free(p); (p) = NULL; } while (0)
The realloc dance¶
T *tmp = realloc(p, new_size);
if (!tmp) { free(p); return -1; } // don't leak p on failure
p = tmp;
The canonical mistake is p = realloc(p, new_size); — if realloc returns NULL, you’ve just leaked the old block and set p to NULL on top of it. Always assign to a temp.
Alignment Guarantees¶
malloc/calloc/realloc return memory suitably aligned for any standard type — formally, aligned to alignof(max_align_t), which is 16 on x86-64 Linux/macOS. That covers everything up to long double and SIMD __m128. For AVX-512 (__m512, needs 64-byte alignment) or CUDA pinned memory (needs page alignment), use aligned_alloc(64, n) or posix_memalign.
A subtle rule: aligned_alloc(align, n) requires n to be a multiple of align. aligned_alloc(64, 100) is undefined behavior. Round up:
size_t rounded = (n + 63) & ~(size_t)63;
void *p = aligned_alloc(64, rounded);
Why You Almost Never Call realloc In a Loop¶
This is the pattern that looks innocent and destroys performance:
T *p = NULL;
size_t n = 0;
for (int i = 0; i < N; ++i) {
T *tmp = realloc(p, (n + 1) * sizeof *p); // grow by 1 each time
if (!tmp) { free(p); return -1; }
p = tmp;
p[n++] = compute(i);
}
Each realloc may copy the whole buffer. Total work is O(N²). The fix is geometric growth — a vector-style capacity that doubles when full:
size_t len = 0, cap = 0;
T *p = NULL;
for (int i = 0; i < N; ++i) {
if (len == cap) {
cap = cap ? cap * 2 : 16;
T *tmp = realloc(p, cap * sizeof *p);
if (!tmp) { free(p); return -1; }
p = tmp;
}
p[len++] = compute(i);
}
Amortized O(1) per push, at most 2x memory overhead. This is what std::vector, Rust’s Vec, and Python’s list do internally. In C, you do it by hand — or better, use an arena (next file).
calloc vs malloc-then-memset¶
For large allocations (multi-KB), calloc is often faster than malloc + memset because on Linux the kernel hands you fresh zero-filled pages via mmap and never actually touches them until first write (demand-paging). So the zeroing is “free.” For small allocations, calloc and malloc+memset are roughly equivalent — calloc typically calls memset internally.
Use calloc when you semantically want zeros. Don’t use calloc as a “safety net” for uninitialized reads — fix the bug at the read site.
Custom Allocators as Preview¶
malloc/free are general-purpose. They’re excellent, but general-purpose costs cycles — typical malloc implementations do 50-200ns of bookkeeping per call, and produce fragmentation over time. Domain-specific allocators can be 5-50x faster:
Arena / bump allocator — allocations are pointer bumps (~5 cycles). All memory freed together. Perfect for per-request, per-frame, per-parse scopes.
Pool allocator — fixed-size blocks in a freelist. Perfect for containers with uniform node size (linked list, tree).
Freelist / slab — what jemalloc and mimalloc do internally, but at a lower level than you’d write.
See 06_custom_allocators.md for implementations. The Ryan Fleury / Chris Wellons school of C basically argues: use arenas for 90% of your allocation, use malloc only when you truly need per-object lifetime. Once you feel it, you don’t go back.
What Most People Get Wrong About This¶
They treat free as “delete this object.” It isn’t. free releases the memory — the object’s lifetime (in the C standard sense) is your responsibility. Fields that own further heap allocations must be freed first, before the parent block. A destructor pattern helps:
void user_destroy(User *u) {
if (!u) return;
free(u->name); // free owned string first
free(u->tags); // free owned array next
free(u); // then the parent
}
Get the order wrong and you’ll free fields through an already-freed struct — use-after-free, spectacular corruption.
Return to README.md · Next: 04_ownership_and_lifetimes.md