AlgoMaster Logo

How malloc Works

40 min readUpdated August 7, 2026
Listen to this chapter
Unlock Audio

A request handler needs space for a small object:

The call returns a pointer, often without entering the kernel. That may seem surprising: the operating system owns the process address space, so how can a library function hand out memory by itself?

The answer is that malloc() manages memory at a different layer.

The allocator obtains relatively large address ranges from the operating system. It then divides those ranges into smaller blocks, remembers which blocks are free, and satisfies most application requests by reusing those blocks. A kernel request is necessary only when the allocator needs more memory or can return a suitable range.

The middle layer is where most malloc calls begin and end. The kernel is involved only when the allocator runs out of the memory it already holds.

malloc() is usually a user-space suballocator, not one system call per allocation.

Understanding this layering explains allocation speed, metadata, fragmentation, why free() needs no size argument, and why freeing an object does not necessarily reduce a process's memory footprint.

The Allocation API Contract

The C allocation API exposes four core operations:

FunctionPurpose
malloc(size)Allocate at least size bytes with unspecified initial contents
calloc(count, size)Allocate an array and initialize its bytes to zero
realloc(pointer, new_size)Resize an existing allocation, possibly moving it
free(pointer)Release a previously allocated block

Include <stdlib.h> to use them:

A successful malloc() returns a pointer suitably aligned for ordinary C object types covered by its contract. The allocation is disjoint from every other live allocation and remains valid until it is freed or successfully reallocated.

The returned memory is not initialized. Reading it before storing meaningful values can produce indeterminate data and undefined program behavior.

free(NULL) performs no operation. Any other pointer passed to free() must be the exact start pointer returned by a compatible allocation function—such as malloc(), calloc(), or realloc()—and must not have already been freed.

The API does not promise:

  • That consecutive calls return consecutive addresses
  • That the physical memory is contiguous
  • That a system call occurs
  • That freed bytes are immediately returned to the operating system
  • That allocation order will remain stable between runs

The interface deliberately hides those implementation choices.

malloc() as a Library Function

Application code calls the allocator through the C library or another runtime:

Most calls take the short branch and never reach the kernel. That is why malloc is usually fast and occasionally is not.

The allocator's metadata, free lists, and caches live in the process address space. Searching those structures and marking a block busy requires ordinary user-space instructions, not a privilege transition.

This is why a loop containing one million small malloc() calls does not normally cause one million memory-management system calls. The allocator obtains memory in batches and amortizes the kernel interaction across many application allocations.

The operating system knows about the larger regions it gave to the process. It does not normally track every 48-byte request object within one allocator arena.

Arenas and Chunks

An allocator manages one or more large regions often called arenas, heaps, or pools. Terminology varies among implementations.

Within an arena, memory is divided into chunks:

A chunk is the allocator's unit of bookkeeping. It is larger than or equal to the payload requested by the application because it may include:

  • A metadata header
  • Alignment padding
  • A rounded-up payload size
  • Debugging or hardening data

The application receives a pointer to the payload, not to the beginning of the allocator's metadata.

The word heap can be confusing here. It is used for at least three things:

  1. The traditional process region expanded through the program break
  2. All storage managed by a dynamic allocator, including separate mappings
  3. The tree-shaped priority-queue data structure

malloc() concerns the first two meanings, not the priority-queue data structure. A modern allocator's managed storage may include the traditional [heap] region and additional anonymous mappings.

Anatomy of a Chunk

A simplified allocated chunk looks like this:

The header lets the allocator recover the chunk's size and state when the application later calls:

This is why free() does not need a size parameter. Starting from the payload pointer, the allocator can find its own metadata.

When the chunk is free, the allocator can reuse some payload bytes for bookkeeping:

Not every allocator uses an inline header or a doubly linked free list. Some store metadata separately, use bitmaps, or organize memory around fixed size classes. The essential requirement is the same: recover the allocation's size and locate reusable blocks efficiently.

Allocator metadata belongs to the allocator. Writing before the returned pointer, past the requested payload, or after free() can corrupt it.

Normalizing a Request

The allocator rarely searches for exactly the number passed to malloc(). It first converts the request into an internal chunk size.

Suppose an allocator has:

For:

the payload may be rounded from 13 bytes to 16 bytes:

This is an illustrative layout, not a promise about a particular C library.

Normalization commonly performs these steps:

  1. Reject or handle impossible sizes.
  2. Add room for metadata.
  3. Round up for alignment.
  4. Enforce a minimum chunk size.
  5. Map the resulting size to a free-list class.

Every addition and rounding operation must be checked for integer overflow. If a huge request wraps around to a small number, the allocator could return a dangerously undersized block.

Free Lists, Bins, and Size Classes

The allocator needs a fast way to find free chunks. Scanning every chunk in address order would become expensive in a long-running process.

A common strategy groups free chunks by size. These groups are often called bins or size classes.

Small requests benefit from exact or narrow classes: choosing a block can be close to a constant-time list operation. Larger requests need policies that avoid wasting a large chunk on a much smaller request.

The allocator may maintain several kinds of free storage at once:

  • Per-thread caches for recently freed chunks
  • Fast lists that delay coalescing
  • Exact-size bins
  • Approximate size-range bins
  • A general structure for large free chunks
  • A special remainder at the end of an arena

These are implementation techniques, not part of the malloc() API. Exact sizes and policies can change between allocator versions.

The Allocation Fast Path

For a typical small request, malloc() follows a path like this:

No system call is needed when a cached chunk is available.

If the fast path misses, the allocator searches broader arena structures. It may find an exact-size chunk, select a larger chunk and split it, consolidate free chunks, or obtain more address space from the operating system.

The common case is deliberately short because allocation can occur in performance-critical code.

Splitting a Larger Chunk

Suppose the allocator needs a 64-byte internal chunk and finds a free 160-byte chunk:

The 64-byte portion becomes the allocation. The 96-byte remainder goes back into an appropriate free structure.

Splitting is useful only when the remainder is large enough to form a valid free chunk. It needs space for any required metadata, alignment, and minimum payload.

If the leftover is too small, the allocator gives the complete chunk to the request:

The extra 8 bytes become internal fragmentation within the allocated chunk.

Splitting reduces immediate waste, but each new remainder becomes another free block that may or may not fit future requests.

What free() Does

For an ordinary arena chunk, free(pointer) conceptually:

Real allocators often optimize this sequence. A small chunk may enter a thread-local cache immediately without being coalesced. Delaying consolidation makes free() and a later same-size malloc() faster.

The tradeoff is temporary fragmentation. Two adjacent chunks can both be logically free but remain in fast caches until a later operation consolidates them.

Once free() returns, the application no longer owns the object:

Assigning NULL can prevent accidental reuse through that particular variable. It does not repair other aliases that still contain the old pointer.

The allocator is allowed to overwrite freed payload bytes with free-list links, debugging patterns, or other metadata. Reading freed memory is invalid even if the old bytes appear unchanged.

Coalescing Adjacent Free Chunks

Coalescing reverses earlier splits.

Start with:

After B is freed:

The two free chunks cannot merge because C lies between them.

After C is also freed:

All three free chunks are adjacent and can coalesce:

The allocator needs an efficient way to find physical neighbors. A common technique stores size information near chunk boundaries. From one chunk, the allocator can locate the next chunk and determine whether the preceding chunk is free without scanning the complete arena.

This style of metadata is often called a boundary tag. Implementations vary: they may store a footer only for free chunks, encode neighbor state in header bits, or use external metadata.

Coalescing creates a larger candidate for future allocations. It cannot merge chunks separated by a live allocation, and it does not recover padding inside a live chunk.

Obtaining Memory from the Operating System

When existing chunks cannot satisfy a request, the allocator must obtain another region.

On Linux, two historically important mechanisms are the program break and anonymous memory mappings.

Growing the traditional heap

The brk() system call changes the process's program break, which marks the end of the traditional data/heap region. The older sbrk() library interface expresses the change as an increment.

Moving the break upward extends the arena. The allocator can divide the new space into chunks.

Only free space at the high end can be returned by moving the break downward. A free chunk trapped between live chunks cannot be released from this region independently.

Applications should not mix direct brk() or sbrk() management with malloc(). Doing so interferes with allocator assumptions, and sbrk() is considered a legacy interface.

Creating a separate mapping

An allocator can request an anonymous private mapping with mmap().

A separately mapped allocation can often be returned with munmap() when freed because its lifetime is not tied to the middle of one continuous arena.

glibc commonly uses both approaches: arena storage and separate mappings for sufficiently large requests. The threshold is an allocator policy and can change dynamically. Other C libraries and allocators use different strategies.

Do not write application logic that assumes a particular request size will always use brk() or always use mmap().

Why Large Allocations Are Often Treated Differently

Placing a very large allocation inside an ordinary arena can create a long-lived obstacle:

Even after the large block is freed, neighboring live chunks prevent the allocator from returning that middle address range by shrinking the traditional heap.

A separate mapping avoids this problem:

Freeing a separately mapped large allocation removes the mapping outright.

The allocator can return the mapping without moving unrelated chunks.

Separate mappings also carry costs. Creating and removing mappings enters the kernel, changes address-space metadata, and is more expensive than taking a small chunk from a cache. Allocators therefore use policy thresholds rather than mapping every request independently.

The ideal threshold depends on allocation sizes, lifetimes, concurrency, and reuse. A choice that saves system calls for one workload may retain too much memory for another.

Loading simulation...

How calloc() Differs

calloc(count, element_size) allocates space for an array and initializes its bytes to zero:

The separate arguments give the allocator an opportunity to detect overflow in:

This is safer than multiplying first without a check:

For portable manual allocation, check before multiplying:

calloc() is not necessarily implemented as malloc() followed by a byte-by-byte clearing loop. Memory newly obtained from the operating system is already required to appear zeroed to the process for security. The allocator can sometimes use that fact to avoid redundant work. Recycled chunks still need to be cleared before they are returned.

The guarantee is zeroed bytes. It is not a call to a language-level constructor and does not establish complex object invariants.

How realloc() Works

realloc(pointer, new_size) changes an existing allocation while preserving the first:

bytes of content.

Several outcomes are possible.

Shrink in place

If the allocation becomes smaller, the allocator can keep the same pointer and split off a reusable remainder when it is large enough.

Grow in place

If the following chunk is free and large enough, the allocator can merge it into the allocation:

The pointer remains unchanged.

Move the allocation

If in-place growth is impossible, the allocator can:

The old pointer becomes invalid after a successful move.

Use a temporary pointer so that failure does not lose the original allocation:

This is unsafe:

If the call fails, NULL overwrites the only pointer to the still-live original block, creating a leak.

Avoid using realloc(pointer, 0) as a portable substitute for free(). Its treatment has changed across language and library specifications. Call free() explicitly when the desired size is zero.

Alignment and Internal Fragmentation

An allocated pointer must satisfy alignment requirements. The allocator therefore rounds chunk starts and sizes.

For many small objects, the overhead can be a significant fraction of useful data:

Even if only one byte is used, the allocator may consume a complete minimum chunk.

Internal allocator waste can come from:

  • Header and hardening metadata
  • Rounded payload sizes
  • Minimum free-chunk size
  • Stronger-than-default alignment
  • A remainder too small to split

Requesting an unusually strong alignment can leave prefixes, suffixes, or larger size-class rounding. Use specialized aligned allocation only when the data or hardware interface actually requires it.

The allocator balances memory efficiency against fast lookup, alignment guarantees, and compact metadata.

External Fragmentation Inside an Arena

An arena can have substantial free memory but no free chunk large enough for a request:

There are 60 units free, but a 32-unit request cannot fit.

The allocator may respond by:

  • Coalescing cached adjacent chunks
  • Searching another arena
  • Extending an arena
  • Creating a new mapping
  • Failing if no suitable memory can be obtained

It normally cannot compact arbitrary C allocations by moving them. Application pointers can be copied into unknown locations throughout the process, and the allocator has no safe way to find and update every copy.

This immovability is a major difference between native malloc() and a managed runtime that controls all object references.

As a result, a long-running native process can retain sparsely used arenas. The allocator knows that many chunks are free, but live chunks between them prevent returning complete ranges to the operating system.

Why free() May Not Reduce Process Memory

Calling free() transfers ownership from the application back to the allocator. It does not necessarily transfer the memory back to the operating system.

The allocator often retains freed chunks because reuse is cheaper than returning memory to the kernel and then requesting it again later.

Several conditions can keep memory in the process:

  • The chunk is cached for a future request.
  • Other live chunks share the same arena or mapping.
  • The free space is not at an arena boundary that can be released.
  • The allocator's retention policy prefers future speed.
  • Fragmentation prevents a releasable contiguous range from forming.

A separately mapped large allocation is more likely to be unmapped immediately. A large free range at an arena boundary may also be trimmed. Some allocators can tell the operating system that selected contents are no longer needed while preserving the address range for later reuse.

The important ownership ladder is:

free() moves memory to the middle state, not the right one. That is why a process can free everything and still show unchanged memory usage from the outside.

Stopping at the middle step is normal behavior, not evidence that free() failed.

Multiple Arenas and Thread Caches

One global free list protected by one lock would become a bottleneck when many threads allocate concurrently.

Modern allocators improve scalability with techniques such as:

  • Per-thread caches
  • Multiple independently locked arenas
  • Size-class-specific synchronization
  • Batching transfers between local and shared structures

In glibc, multiple arenas allow threads to perform many allocations without contending for one global arena lock. A thread-local cache can satisfy common small requests without taking an arena lock at all.

This improves throughput, but memory becomes distributed:

A request associated with one thread or arena may obtain more memory even though reusable chunks sit elsewhere. Thread caches and multiple arenas can therefore increase retention and fragmentation.

The allocation functions are thread-safe on POSIX systems. That does not make the allocated object safe for concurrent access; object synchronization remains the application's responsibility.

Thread-safe also does not mean safe inside an asynchronous signal handler. Allocation can need locks and modify complex shared state, so malloc() and free() are not async-signal-safe operations.

Virtual Addresses Returned by malloc()

malloc() returns a logical, or virtual, address in the current process.

It does not promise:

  • One physically contiguous RAM range
  • A stable physical address
  • Immediate physical backing for every byte
  • An address meaningful in another process

A 1 GiB allocation can return one contiguous logical range even though its physical backing is managed independently. On systems with optimistic allocation policies, a non-NULL result also may not guarantee that every byte can later be backed successfully when touched.

Application code should therefore treat successful malloc() as an address-space and allocator contract, not as proof that a matching amount of RAM has been permanently reserved.

If a device or another low-level consumer requires a special physical layout or alignment, ordinary malloc() is not sufficient merely because it returns a contiguous C pointer.

Allocation Failure and Integer Overflow

malloc() returns NULL when it cannot satisfy a nonzero request. On POSIX systems, it also sets errno to ENOMEM.

Failure can result from more than exhausted RAM:

  • The requested size is impossible or overflows an internal calculation.
  • The process reaches an address-space or resource limit.
  • The allocator cannot obtain a suitable new region.
  • The current free blocks are too fragmented.
  • The system rejects a new mapping.

Always check allocation results before dereferencing them.

Array allocation needs an overflow check:

The check must happen before multiplication. Testing the product afterward is too late because unsigned arithmetic may already have wrapped.

Avoid depending on zero-size allocation behavior. Standards and implementations have differed over whether a zero-size request returns NULL or a distinct freeable pointer. Handle an empty logical collection explicitly.

Allocator-State Corruption from Ownership Errors

The allocator relies on application code to honor the API contract.

Double free

The first call can place the chunk in a free list. The second can insert the same chunk again or make the allocator interpret modified payload bytes as metadata.

Use after free

The chunk may already belong to another allocation or contain free-list links.

Freeing an interior pointer

The allocator expects the exact payload start. From an interior address it cannot reliably locate the correct chunk metadata.

Buffer overflow

The write can damage the next chunk or its header.

Mismatched ownership

Stack variables, globals, and memory obtained from unrelated APIs must not be passed to free().

Hardened allocators can detect some corruption and terminate the process before it becomes exploitable. Detection is not guaranteed, and a crash inside malloc() or free() often means the allocator noticed damage caused earlier.

Workload-Dependent Fragmentation

Two services using the same allocator can have very different memory footprints.

A stable workload that repeatedly allocates and frees a few common sizes can reuse chunks efficiently. A workload that alternates long-lived small objects with short-lived large objects can leave live chunks scattered through arenas.

Important factors include:

  • Distribution of requested sizes
  • Order of allocation and release
  • Object lifetimes
  • Thread count and arena selection
  • Alignment requirements
  • Whether large allocations use separate mappings
  • How quickly caches consolidate or release memory

This is why allocator behavior should be measured using the real workload. A microbenchmark that immediately frees every allocation in reverse order may coalesce perfectly while production traffic fragments memory over days.

Changing allocators can improve throughput or memory retention, but it cannot correct missing free() calls or invalid ownership. It also changes tradeoffs rather than eliminating them.

Observing malloc() with strace

The following Linux program performs 1000 small allocations and one large allocation:

Compile it:

Trace only the Linux interfaces commonly used to obtain and release allocator regions:

The trace will include mappings created by the program loader and C library before main() begins. Even so, the important shape is visible:

  • The program makes 1001 calls to malloc().
  • The trace normally shows far fewer brk() and mmap() calls.
  • On glibc, the large allocation will commonly appear as a separate anonymous mapping.
  • free(large) may produce a corresponding munmap().
  • Freeing the small blocks may not shrink the heap immediately.

Exact results depend on the C library, allocator version, tunables, and previous allocator state. The experiment demonstrates batching and reuse, not one universal threshold.

While the program waits, inspect its mappings from another terminal:

Replace <pid> with the printed PID. Look for [heap] and large writable anonymous mappings. The mapping boundaries belong to the allocator's operating-system regions, not to each individual 1024-byte block.

Press Enter to let the process free its allocations and exit.

A Concrete glibc Mental Model

glibc's allocator is derived from ptmalloc, which in turn grew from Doug Lea's allocator. Its exact internals evolve, but a durable high-level model is:

Small and large requests take entirely separate routes. Size alone decides which, which is why an allocation just over the threshold can behave very differently from one just under it.

glibc uses chunk metadata, multiple categories of bins, arena locks, multiple arenas, and a per-thread cache. Some freed chunks are immediately reusable without coalescing; others enter arena structures where neighboring free chunks can be consolidated.

Do not memorize exact bin counts, size cutoffs, or cache limits as properties of malloc() itself. They are glibc implementation details and can change across releases or through runtime tuning.

Other allocators—such as jemalloc, tcmalloc, mimalloc, and specialized language-runtime allocators—organize spans, size classes, caches, and reclamation differently. They still solve the same core problems:

  • Obtain larger regions from the operating system.
  • Divide them into aligned application allocations.
  • Track ownership and free space.
  • Serve common requests quickly under concurrency.
  • Control fragmentation and decide when to release memory.

Summary

malloc() is a user-space allocator layered over operating-system memory interfaces. It obtains large regions, divides them into chunks, stores metadata, groups free chunks by size, and returns aligned payload pointers. Most small allocations reuse existing chunks without a system call.

Allocation can use a cached chunk, split a larger free chunk, extend an arena, or create a separate mapping. free() returns a chunk to the allocator, where it may be cached, coalesced, reused, or eventually returned to the operating system. This is why freeing objects does not necessarily reduce process memory immediately.

Metadata, alignment, minimum chunk sizes, free-list structure, multiple arenas, and thread caches create tradeoffs between speed, concurrency, fragmentation, and memory retention. calloc() adds zero initialization and safer array-size handling, while realloc() may shrink, grow, or move an allocation.

The central mental model is:

The operating system supplies regions; the allocator turns those regions into application-sized objects.

Quiz

How malloc Works Quiz

5 quizzes