Ch. 21 · Operating Systems

Stack vs Heap Memory: Allocation, Lifetimes and Stack Overflow

Stack and heap as lifetime decisions: real addresses, stack overflows at 7,600 vs 400 frames on macOS, and a dangling pointer clang flags.

~6 min readintermediateupdated Oct 12, 2026

“Where do local variables live?” sounds like a beginner question, and “on the stack” is the beginner answer. What interviewers are probing is whether you understand lifetimes: who decides when memory is released, what happens when the rule is broken, and why a program that works in tests can die on a deeply nested input in production. Stack versus heap is a lifetime decision first and a performance detail second.

Where each region sits in a real process

This C program prints the address of a function, a global, two heap allocations, a local in main and a local in a second thread. Compiled with clang on an M1 Mac:

int global_counter = 1;

static void *thread_main(void *arg) {
    int local_in_thread = 0;
    printf("thread stack local %p\n", (void *)&local_in_thread);
    return NULL;
}

int main(void) {
    int local = 0;
    char *small = malloc(64);
    char *big = malloc(64 << 20);
    printf("code (main)        %p\n", (void *)main);
    printf("global variable    %p\n", (void *)&global_counter);
    printf("malloc(64)         %p\n", (void *)small);
    printf("malloc(64 MiB)     %p\n", (void *)big);
    printf("main stack local   %p\n", (void *)&local);
    /* ... print the default pthread stack size, start one thread ... */
}
c
code (main)        0x1000904f8
global variable    0x100098000
malloc(64)         0x10088d860
malloc(64 MiB)     0xc76000000
main stack local   0x16fd6e950
default pthread stack size 512 KiB
thread stack local 0x16fdf6fb4
Text

Code and globals sit together in the program image near 0x100000000. The 64-byte allocation came from a small-object heap region close by. The 64 MiB allocation landed somewhere else entirely: allocators serve large requests from separate regions mapped directly from the kernel (glibc does the same above its mmap threshold, 128 KiB by default). The stacks are up near 0x16fd..., and every thread has its own. Don’t memorise those numbers; address randomisation moves them on every run. The point is that “the heap” is really several regions managed by the allocator, and “the stack” is one region per thread.

The stack: frames that die on return

Each thread has a stack pointer register. Calling a function pushes a frame holding the return address, saved registers and the function’s local variables; on ARM64 or x86-64 that’s one instruction subtracting the frame size from the stack pointer. Returning adds it back. There’s no bookkeeping, no search for free space and no locking, because only one thread ever uses a given stack.

The cost of that simplicity is a rigid lifetime rule: a frame’s memory is valid until its function returns, and not one instruction longer. Locals are also invisible to other threads unless you hand out their address, which brings its own problems.

The other cost is a fixed size. A thread’s stack is reserved when the thread starts, with an unmapped guard page beyond the end. Running into the guard page is a stack overflow, and the kernel kills the process with a signal. To see the limits, this program recurses with 1 KiB of locals per call and prints its depth every 100 calls:

static long depth = 0;
static void recurse(void) {
    char frame[1024];                 /* 1 KiB of locals per call */
    memset(frame, (int)depth, sizeof frame);
    depth++;
    if (depth % 100 == 0) { printf("depth %ld\n", depth); fflush(stdout); }
    recurse();
    __asm__ volatile("" :: "r"(frame) : "memory");   /* keep the frame alive, block tail-call */
}
c

Run on the main thread, on a second pthread, and under ulimit -s 1024:

main thread   : last line 'depth 7600', SIGSEGV
second thread : last line 'depth 400', SIGBUS
ulimit -s 1024: last line 'depth 900', SIGSEGV
Text

The main thread’s stack follows ulimit -s, which is 8176 KiB on this Mac, so it lasted about 7,600 frames. A second thread got the macOS pthread default of 512 KiB and died after about 400, with SIGBUS rather than SIGSEGV because of how macOS reports a hit on that guard page. On Linux with glibc, new threads default to the RLIMIT_STACK value, usually 8 MiB, so the same code survives far deeper there (I didn’t run the Linux case). That difference alone can make a bug appear on one platform and not another.

Managed runtimes put their own limit in front of the OS one:

RecursionError: maximum recursion depth exceeded (reached depth 999)
RangeError: Maximum call stack size exceeded (depth 9155)
--stack-size=4000: RangeError at depth 42606
Text

Python 3.14 stops at its recursion limit of 1,000 frames, an interpreter rule rather than a memory limit. Node 22 checks against V8’s stack budget; --stack-size raises it, but set it larger than the real thread stack and you trade a catchable RangeError for a segfault.

The heap: lifetimes you choose

Heap memory lives until something explicitly releases it: free in C, delete or a smart pointer in C++, the garbage collector in Java, Go, Python and JavaScript. That flexibility is the reason it exists. A request object built in one function, stored in a queue and processed by another thread an hour later can’t live in any one function’s frame.

The allocator gets large chunks from the kernel (mmap, or brk on older Unix designs) and carves them into the sizes you ask for. That’s real work: finding a free block of the right size class, keeping per-thread caches to avoid lock contention, and coping with fragmentation. Freed memory usually returns to the allocator, not to the kernel, which is why a process’s RSS often stays high after a burst of allocations ends.

Some languages make the choice for you. Go and the JVM run escape analysis: if the compiler can prove an object never outlives its function, it may skip the heap entirely. go build -gcflags=-m prints decisions like “moved to heap: x”. So “all objects go on the heap in Java” is the right mental model for correctness but not a statement about the machine code.

When lifetimes are broken

The textbook stack bug is returning the address of a local:

int *make_value(void) {
    int value = 42;
    return &value;              /* the frame holding value dies on return */
}
void unrelated_call(void) {
    volatile int scratch[16];
    for (int i = 0; i < 16; i++) scratch[i] = 7;
}
int main(void) {
    int *p = make_value();
    printf("right after return: %d\n", *p);
    unrelated_call();           /* reuses the same stack bytes */
    printf("after another call: %d\n", *p);
}
c
dangling.c:4:13: warning: address of stack memory associated with local variable 'value' returned [-Wreturn-stack-address]
right after return: 42
after another call: 1
Text

Clang flags it at compile time. The first read still shows 42 because nothing has reused those bytes yet, which is exactly what makes the bug dangerous: it passes a quick test. After two more calls have used that stack space, the pointer reads garbage. Heap bugs are the mirror image: use-after-free (releasing too early) and leaks (never releasing).

Two misconceptions come up a lot. “The stack is faster memory” is wrong; it’s the same RAM. Stack allocation is cheaper because it’s one instruction and the top of the stack is almost always in cache. And the heap in “stack versus heap” has nothing to do with the heap data structure behind priority queues.

Interview exercise: the parser that dies on deep JSON

A service parses JSON with a recursive-descent parser. Tests pass, and the main command-line tool handles every file. In production the parser runs on a worker thread, and the process occasionally dies with SIGBUS on documents with very deep nesting (one had arrays nested 5,000 levels). What’s going on, and how would you fix it?

Answer

Each nesting level costs at least one stack frame, so stack usage grows with nesting depth, and depth is chosen by whoever sends the document. The command-line tool runs on the main thread with an 8 MiB stack, while the production worker is a secondary thread, which on macOS gets 512 KiB by default. In the experiment above that difference was 7,600 frames against 400. The deep document overruns the worker’s stack, hits the guard page, and the whole process dies, since a stack overflow is a fatal signal.

Raising the thread’s stack size with pthread_attr_setstacksize is a quick stop-gap but not a fix: an attacker just nests deeper. The real fixes are to enforce a maximum nesting depth and reject documents beyond it (serde_json stops at 128 levels by default, and Jackson 2.15 and later at 1,000), or to rewrite the parser iteratively with an explicit stack on the heap, which can grow until a memory limit you choose. I’d also add a fuzz test with pathological nesting so the limit is checked in CI, not discovered in production.

More on memory lifetimes

More in Operating Systems

read ✓Operating Systems · hard

Operating-System Page Cache and I/O Measurements

Random 4 KiB reads took 189 µs cold and 1.6 µs warm on an M1. How the page cache works, how to benchmark around it, and when to bypass it.

~7 min readread →
read ✓Operating Systems · easy

Paging, Page Faults and Memory Access

Minor, major and invalid page faults, with real counts: 4,096 faults for 64 MiB on an M1, and a cold mmap read 34 times slower than warm.

~6 min readread →
read ✓Operating Systems · hard

Operating Systems: CPU Cache Locality

Measured on an M1: column-order matrix walks up to 30x slower, shuffled lists at 97 ns a node, and false sharing slowing two counters 4x.

~6 min readread →
esc