What Happens When I Allocate Memory?
new Object(), obj = SomeObject() and malloc(64) all end in the same place — a user-space allocator handing out slices of pages it obtained from the kernel’s virtual memory system, with the physical memory appearing only when a page is first touched — but the three runtimes take very different routes to get there.
The problem
new and the bytes?From new to the bytes: the layers
The request passes through three actors with three different units. The language runtime deals in objects: it knows the type, the size, and who will free it (you, a refcount, or a collector). The allocator — libc malloc, jemalloc, V8’s heap, CPython’s pymalloc — deals in blocks: it keeps large regions carved into pieces and hands out a piece of the right size. The kernel deals in pages: it extends the process’s address space by whole pages on request and backs them with physical frames only when they are touched. Almost every allocation is satisfied by the allocator without involving the kernel; the kernel is called only when the allocator’s regions are exhausted.
- Source: new Node() / Node() / malloc(64)a request for N bytes with a lifetime↓
- Language runtimecomputes the object size, picks the heap (young space, arena, size class), may bump-allocate↓
- Allocator: free list / size class / arenapops a free block of the right class: ~20 ns, no syscall↓
- Existing heap has room?yes → done. no → ask the kernel for more address space↓
- Kernel: brk / mmap (VirtualAlloc on Windows)extends the mapping by pages; no physical memory yet↓
- First touch → page faultkernel allocates a zeroed frame and maps it: ~1 µs per 4 kB page↓
- Physical RAMthe bytes exist now; the object’s address has not changed
The allocator: size classes, free lists, arenas
A general-purpose allocator has to answer malloc(64) fast, free(p) fast without being told the size, and do both from many threads. The universal design: round each request up to a size class (glibc uses 16-byte steps for small sizes; jemalloc and tcmalloc use ~40 classes from 8 bytes to a few kB), keep a free list per class, and serve a request by popping the list head. Freed blocks are pushed back onto their class’s list; the size is recovered from a header before the block or from which page it lives in. Large requests (glibc: above 128 kB by default) skip the classes and get their own mmap, so free can return them to the kernel immediately.
Memory for the lists comes from arenas: regions of a few megabytes obtained from the kernel and sliced into runs of one size class each. Multi-threaded allocators give each thread its own cache of free blocks (glibc’s tcache, jemalloc’s tcache, tcmalloc’s per-CPU caches) so that a thread’s malloc/free never takes a lock in the common case, and fall back to per-arena locks only when the cache is empty. This is why replacing glibc malloc with jemalloc or mimalloc can change a multi-threaded server’s throughput and fragmentation noticeably: the size-class boundaries, the arena count and the return-to-OS policy differ.
What the allocator returns to the kernel matters for what top shows. A freed block goes back to a free list, not to the OS; the process’s RSS stays where it was. Allocators return memory only when whole pages or arenas are empty (madvise(MADV_DONTNEED) or munmap), and glibc’s heap only shrinks from the top. A process that allocated 2 GB, freed it all, and now uses 10 MB may still show 2 GB resident — not a leak, but not free either.
- Small allocation: size class → free list pop, ~20–50 ns, no syscall, no lock with a thread cache.
- Large allocation (≥ 128 kB in glibc): direct
mmap, ~1–2 µs plus a page fault per page touched. - Freeing returns blocks to the allocator, not to the kernel; RSS rarely shrinks.
malloc_trim,MALLOC_ARENA_MAX,jemallocbackground_thread— knobs that change when memory goes back.
When the heap runs out: brk, mmap, VirtualAlloc
The Unix heap has two doors to the kernel. brk/sbrk moves the program break — the end of the data segment — upward, growing one contiguous region; it is the classic heap and glibc still uses it for the main arena. mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) asks for a fresh region anywhere in the address space; secondary arenas, large blocks and thread stacks come from it. Both return address space, not memory: the kernel records a new virtual memory area with its permissions and returns immediately. No frames are allocated, no zeroing happens, and the syscall costs about a microsecond regardless of whether you asked for 4 kB or 4 GB.
On Windows the equivalent is VirtualAlloc, with an explicit two-step model that Unix hides: MEM_RESERVE claims address space, MEM_COMMIT promises backing (charged against the system commit limit) and only a first touch allocates a physical page. The Windows heap (HeapAlloc, and the CRT malloc on top of it) sits above VirtualAlloc in the same way glibc sits above brk/mmap. The distinction matters because Windows does not overcommit: a commit that exceeds RAM + pagefile fails immediately, where a Linux mmap of the same size would succeed and fail later — see Memory Pressure, Swap and the OOM Killer.
1malloc(n):2 cls = size_class(n)3 if tcache[cls] not empty: return pop(tcache[cls]) # ~20 ns4 if arena.bins[cls] not empty: lock arena; return pop(bin) # ~50 ns5 if n >= 128 KiB: return mmap(round_up(n, 4 KiB)) # ~1 µs + faults6 if arena.top has room: carve from top; return # ~50 ns7 brk(+132 KiB) or mmap(64 MiB) # ~1 µs: address space only8 carve from the new region; return # first write → page faultPages exist only when touched
After mmap returns, the region is mapped in the page table as not present. The first load or store to each 4 kB page traps into the kernel (Page Faults), which finds the address inside a valid area, allocates a physical frame, zeroes it (the kernel never hands one process another’s old bytes), installs the mapping and resumes the instruction. This is first-touch or demand allocation, and it costs roughly 0.5–2 µs per page including the zeroing — so touching a fresh 1 GB buffer costs ~250,000 faults and a quarter of a second before any useful work, which is why calloc of a huge buffer is fast (it is all untouched zero pages) and the first pass over it is slow.
It is also why "virtual size" and "resident size" diverge: a process may have 40 GB mapped and 2 GB resident, and only the resident part is real. Applications that need the memory to be there now — databases, real-time systems, anything measuring latency — pre-fault it deliberately: MAP_POPULATE, mlock, or simply writing one byte per page at startup. Allocators that know a region will be hot do the same.
Two refinements change the numbers. Transparent huge pages (Linux) may back a first touch with a 2 MB page instead of 4 kB — 512× fewer faults, at the cost of committing 2 MB for a single byte and of latency spikes when the kernel compacts memory to find contiguous 2 MB frames; databases (Redis, MongoDB, Oracle) document whether to turn it off. And MAP_HUGETLB/hugetlbfs reserve true huge pages up front for the cases where the TLB benefit (The TLB) is worth the management.
Three runtimes, three stories
C++ (new, std::make_unique, containers): the compiler emits a call to operator new, which calls malloc, which is the path above. The runtime adds nothing between you and the allocator except the constructor. Freeing is your responsibility, made deterministic by RAII: a std::vector going out of scope runs its destructor, which calls operator delete → free at that exact moment. The cost model is the allocator’s: ~20–50 ns per small allocation, no pauses, and fragmentation as the long-run risk. Custom allocators (arenas, pools, PMR) are common exactly because the general-purpose path is the only overhead there is.
JavaScript/TypeScript (V8): new Object() does not call malloc. V8 owns a large heap it obtained from the OS in advance, and allocates by bump pointer in the young generation (new space, a few MB of semi-spaces): increment a pointer, write the object header — a handful of instructions, faster than malloc. When new space fills, the scavenger copies the live objects (usually a small minority) into the other semi-space and, after surviving twice, into the old generation; the dead ones are never touched. Old space is collected by a concurrent mark-sweep-compact; large objects (≥ ~500 kB) get their own large-object space. Cost model: allocation is nearly free, freeing is batched into GC work proportional to *live* data, and the failure modes are pauses and the --max-old-space-size limit (defaults on the order of 2–4 GB), not fragmentation.
Python (CPython): obj = SomeObject() allocates a PyObject (16-byte header holding a refcount and a type pointer, plus instance data, plus a __dict__ unless __slots__) via pymalloc for objects up to 512 bytes and via malloc above that. pymalloc keeps 256 kB arenas divided into 4 kB pools, one size class (8-byte steps) per pool, with free lists per pool — a small-object allocator tuned for CPython’s pattern of many tiny, short-lived objects. Freeing is reference counting: when the last reference dies the object is freed immediately and deterministically, so a loop’s temporaries never accumulate. The cyclic GC runs only to find garbage cycles, in three generations triggered by allocation counts (700 net young allocations by default; 3.12+ uses a two-generation incremental scheme), and is the source of the occasional pause. Cost model: every value is an allocation, refcounting adds writes on every assignment, and the GIL serialises the allocator anyway.
| C++ | JavaScript / TypeScript (V8) | Python (CPython) | |
|---|---|---|---|
| new X / X() | operator new → malloc → size class | bump pointer in new space | pymalloc pool (≤ 512 B) or malloc |
| Cost of a small allocation | ~20–50 ns | ~few ns | ~50–100 ns incl. object header + dict |
| Who frees | You / RAII destructor | Scavenger (young) / mark-sweep-compact (old) | Refcount → immediate; cyclic GC for cycles |
| When memory is reclaimed | At delete / scope exit | At the next GC of that generation | Immediately on last reference; cycles at GC |
| Pauses | None (allocator worst case only) | Minor GC ~1 ms, major GC longer, mostly concurrent | Cycle collection, usually short |
| Typical failure | Leak, use-after-free, fragmentation | Retained references → heap limit → crash | Reference cycles with __del__, C-extension leaks |
| Talks to the kernel via | brk / mmap through malloc | mmap for heap pages, managed by V8 | malloc (arenas are 256 kB mmap regions) |
Key points
- Three layers, three units: runtime (objects), allocator (blocks in size classes), kernel (pages). Most allocations never reach the kernel.
- Allocators use size classes, free lists, arenas and per-thread caches; a small allocation is ~20–50 ns with no syscall.
- When arenas run out the allocator calls
brk/mmap(Unix) orVirtualAlloc(Windows) — which return address space, not memory. - Physical frames appear on first touch via a page fault (~1 µs per 4 kB page, including zeroing); virtual size and resident size differ for this reason.
- Freed memory goes back to the allocator, not the OS; RSS rarely shrinks after a free.
- C++ = malloc plus deterministic destructors; V8 = bump allocation plus generational GC; CPython = pymalloc plus refcounting plus a cycle collector. Their cost models and failure modes are different.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why doesn’t malloc just call the kernel every time?
A syscall costs ~1 µs and a page fault another; the kernel deals in 4 kB pages. An allocator turns one page into 64 sixteen-byte blocks and serves each in 20 ns. Batching the kernel is its entire purpose.
▸Why is memory allocated lazily on first touch?
Programs routinely reserve far more than they use — thread stacks, large buffers, sparse tables. Backing pages on demand means unused reservations cost nothing, and a fresh page is zeroed exactly when needed.
▸Why can V8 allocate faster than malloc?
Because it does not have to support freeing individual objects in arbitrary order. A bump pointer in a region that will be evacuated wholesale by the scavenger has no free lists, no headers to search, no size classes.
▸Why does Python free memory immediately while JavaScript waits for a GC?
CPython counts references, so the moment the count hits zero the object is provably dead. V8 does not count; it traces reachability in batches. Refcounting costs a write per reference change; tracing costs pauses proportional to live data. Each runtime picked one.
What happens when I allocate memory?
- Language runtime↓
- Allocatorfree lists / size classes / nursery↓
- Existing heap has room?↓
- Ask the OSbrk / mmap · VirtualAlloc↓
- Pages reserved (virtual)↓
- First toucha store to the new page↓
- Page fault#PF → kernel↓
- Physical frame mappedzeroed, PTE written
auto p = new Buf(…)
How it fails
What the failure looks like from inside real software.
- RSS stays high after a big batch job "freed everything": the allocator kept the pages; a
malloc_trim(0)call or jemalloc’s decay settings return them. - A service’s first request after start is 10× slower than the rest: first-touch faults on the buffers; pre-fault (
MAP_POPULATE, touch at startup) fixes it. - Node crashes with "FATAL ERROR: Reached heap limit Allocation failed": live objects exceed
--max-old-space-size; it is a retention bug (a Map used as a cache with no eviction), not a lack of RAM. - A Python service’s memory climbs forever: objects in reference cycles that define
__del__(pre-3.4) or a C extension leaking, invisible togc.collect(). - A multi-threaded C++ server’s RSS is 3× its live data: glibc created one arena per thread (up to 8 × cores) and each holds fragmented free blocks;
MALLOC_ARENA_MAX=2or jemalloc fixes it. - Transparent huge pages turned a 4 kB write into a 2 MB commit and a compaction stall:
khugepagedat 100% and p99 latency spikes; the database’s docs said to disable THP.
Follow it through every layer
This lesson is one node of a longer journey. Zoom out, then zoom back in.