kafi.dev

Stack vs heap: what actually lives where

· 10 min · JDK 21

The two regions every running program uses, what Java actually puts in each, and the places the usual explanation quietly gets it wrong.

The stack and the heap are where almost all of a running program’s data sits. Most explanations of them are roughly right and specifically wrong, usually in the same four places. This is my attempt at getting it right, checked against a running VM rather than recalled.

Both are regions of a process’s virtual address space. They are backed by RAM, but a given page may be in cache, in RAM, or swapped to disk, and the addresses a program sees are virtual ones the operating system maps on its behalf. RAM is called volatile because it loses its contents without power. That is all the word means here — it is not a collective name for the regions a program runs in.

A process gets several regions. The machine code, a region for global and static data, one stack per thread, and the heap.

That middle region is where the language matters. In C and C++, globals and statics are laid out at compile time into fixed-size sections, and the size really is known before the program runs. Java does not work this way. A static field lives in the heap, attached to the class, and the class metadata describing it lives in Metaspace. Neither is sized at compile time. Both are allocated as classes load.

The stack

LIFO, exactly like the data structure. One stack per thread, never shared.

The stack holds frames. A frame is pushed when a method is called and popped when it returns, which is what makes the call stack a record of how you got here.

A frame holds the return address, the parameters, and the method’s local variables. Two things worth separating, because the usual phrasing runs them together:

So “primitives go on the stack” is only true of locals. It is not a property of the type.

A thread stack made of call framesA single thread's stack drawn as a column. Three frames are stacked: main at the bottom, then price, then helper at the top, which is the currently executing method. Above them is unused stack space. Frames are pushed when a method is called and popped when it returns. One frame is expanded to show what it contains: the return address, the parameters passed by value, local primitives holding their values directly, and local reference variables holding a reference rather than an object.one thread's stackunusedhelper()running nowprice()main()push on call, pop on returninside one framereturn addressparameters, copied in by valuelocal primitives, holding the value itselflocal references, holding a reference, not an object
A frame is pushed on call and popped on return. Because a method’s locals are known when it is compiled, the frame’s size is known too, and allocating one is a pointer bump.

How stack allocation works

One clarification, because it is easy to state two contradictory things here. A thread’s stack has a fixed maximum, chosen when the thread starts. The used portion grows and shrinks as calls come and go. On the build I checked, that maximum defaults to 2 MB:

$ java -XX:+PrintFlagsFinal -version | grep ThreadStackSize
   intx ThreadStackSize    = 2048    {pd product} {default}

That is 2048 KB, per thread, on Zulu 21.0.10 running macOS on aarch64. It is platform-dependent, and -Xss overrides it.

The heap

The heap is the larger region, shared by every thread, and it is where objects go. Its defining property is that lifetime is not tied to a call: an object outlives the method that created it, for as long as something still refers to it.

So how do you refer to it?

In C you would say a pointer, and a pointer is a memory address. Java gives you references, and the distinction is not pedantic. You cannot do arithmetic on a Java reference, and a moving collector is free to relocate the object it names during compaction. A reference is a handle the VM can keep valid across a move, not an address you can rely on.

How a stack frame refers to objects on the heapA stack frame on the left holds four local variables. The primitive local qty holds the value five directly in the frame. Two reference locals, a and b, both point at the same Order object on the heap, showing that two references can name one object. The local c is null and points at nothing at all. On the heap, the Order object contains its own primitive field qty holding the value five inside the object rather than on the stack, and a reference field symbol pointing to a separate String object.frame: place()int qty = 5Order aOrder bOrder c = nullthe value itself sits in the frameheapOrderint qty = 5String symbola primitive field sits inside the objectString"VOD.L"two references, one objectnull is the absence of a reference,not an empty slot in the heap
The frame holds references; the objects are elsewhere. Two locals can name one object, in which case there is one object and mutating through either is visible through both.
static int place(int quantity) {
    // A primitive local. The value 5 is written into this frame.
    int fee = 5;

    // A reference local. The reference is in the frame; the Order is not.
    Order order = new Order();

    // Both writes reach into the object on the heap, not into this frame.
    order.quantity = quantity;
    order.symbol = "VOD.L";

    // Two locals, one object. Nothing is copied.
    Order alias = order;
    alias.quantity += 1;

    // Not a pointer: no arithmetic is possible on it, and the collector is free to
    // move the object it names, so it is not a stable address.
    Order none = null;

    return order.quantity + fee + (none == null ? 0 : 1) + alias.quantity;
}
code/memory/src/main/java/dev/kafi/memory/WhereItLives.java · frame

That sample prints 11, and the reason it prints 11 rather than 10 is the aliasing: alias and order are the same object, so alias.quantity += 1 is visible through order.

Sharing is where the heap gets dangerous. Because every thread can reach the same object, unsynchronised mutation is a data race, and the result is not merely a stale read — it can be a torn or impossible state.

Two failures are worth naming precisely, because both are usually described wrongly:

And the deallocation story is the opposite of the C one. In Java you cannot free an object manually; there is no free, and finalize is deprecated for removal. The garbage collector reclaims anything unreachable, automatically. The failure mode is not forgetting to free — it is retaining a reference to something you are finished with, at which point the collector is obliged to keep it. A cache or a static collection that only ever grows is the usual culprit.

Automatic is not the same as deterministic, which is the part that matters if you care about latency. You do not choose when the work happens.

Regions of the heap

The Java heap next to Metaspace, which is not part of itOn the left, the Java heap, bounded by the maximum heap size, is divided into a young generation containing Eden and two survivor spaces, and an old generation. On the right, drawn as a separate region, is Metaspace, which holds class metadata and lives in native memory bounded by its own flag. Metaspace replaced the permanent generation, which was removed in Java 8, and is not part of the heap.Java heap — bounded by -Xmxyoung generationEdenS0S1old generationsurvivors of enoughyoung collectionsMetaspace — native memoryclass metadata-XX:MaxMetaspaceSizePermGen was removed in Java 8 (JEP 122); Metaspace replaced it.Eden, survivors and old are the generational layout, which is collector-specific.
Metaspace is drawn outside the heap because that is where it is. It replaced the permanent generation, which was removed in Java 8.

A generational collector splits the heap:

The third item in that list is usually wrong. It is not “permanent generation”:

That last claim is checkable rather than something to take on faith. Give the heap and Metaspace different limits, and native memory tracking reports them as separate categories:

$ java -Xmx64m -XX:MaxMetaspaceSize=32m -XX:NativeMemoryTracking=summary \
       -XX:+UnlockDiagnosticVMOptions -XX:+PrintNMTStatistics -version
-  Java Heap (reserved=67108864, committed=67108864)
-      Class (reserved=33623824, committed=134928)

64 MB of heap and roughly 32 MB of class space, counted separately. On this JDK there is no MaxPermSize flag at all — -XX:+PrintFlagsFinal has nothing matching PermSize.

One caveat on the diagram: Eden, survivors and a promoted old generation are how Serial, Parallel and G1 present the heap. The layout is a property of the collector, not of the heap itself.

Objects do not always go on the heap

“Objects go on the heap” is the default, not a guarantee. When C2 can prove an allocation never escapes the method, it can take the object apart and keep the fields in registers or in the frame — scalar replacement. Both switches are on by default:

$ java -XX:+PrintFlagsFinal -version | grep -E 'DoEscapeAnalysis|EliminateAllocations'
   bool DoEscapeAnalysis      = true    {C2 product} {default}
   bool EliminateAllocations  = true    {C2 product} {default}

This matters when you are chasing allocation. An object that appears in the source need not appear in the profile, and an object that escapes into a field or another thread will never be eliminated no matter how short its apparent life.

Key differences

StackHeap
SharedOne per thread, privateOne per JVM, shared by all threads
HoldsFrames: return address, parameters, localsObjects and arrays, plus static fields
LifetimeTied to the call; gone on returnUntil nothing refers to it
AllocationPointer bump, layout fixed at compile timeBump within a thread-local buffer, then slower paths
Reclaimed byPopping the frame; nothing to freeThe garbage collector, at a time you do not choose
SizeFixed maximum per thread, -XssBounded by -Xmx, grows within that
ExhaustionStackOverflowErrorOutOfMemoryError
FragmentationNone; strictly LIFOPossible, which is why collectors compact

Checking any of this yourself

Everything above came from these, on Zulu 21.0.10, macOS, aarch64:

java -XX:+PrintFlagsFinal -version | grep -iE 'ThreadStackSize|Metaspace|PermSize'
java -XX:+PrintFlagsFinal -version | grep -iE 'DoEscapeAnalysis|EliminateAllocations'
java -Xmx64m -XX:MaxMetaspaceSize=32m -XX:NativeMemoryTracking=summary \
     -XX:+UnlockDiagnosticVMOptions -XX:+PrintNMTStatistics -version

Flag defaults move between releases and differ by platform, so the numbers above are mine, not yours. Run them on the JDK you actually ship on.