The idea: frames per thread, objects in one shared heap

A Java program keeps data in two places. Every thread has its own stack: one frame per method call, holding that call's local variables. All threads share one heap: every object made with new lives there. A local variable of a primitive type (int, boolean, …) holds its value right in the frame. A local variable of a class type holds a reference: a small pointer to an object on the heap. On the canvas that reference is an arrow.

The main thread stack has frame main with slots args and c holding references and local = 5 holding the value; thread T2 stack has frame work with slots c and mine holding references; in the shared heap both c slots point to the same Counter, and mine points to a Point with x = 7, y = 7
Primitives sit in the frame itself; objects live in the shared heap and frames only hold references to them.

The page runs real javac bytecode, one instruction per step, on a small model of HotSpot (64-bit, compressed references). The sizes of objects and frames follow HotSpot's rules; the stack and heap capacities are scaled down so they fit on the screen.

A JVM frame: local variables and the operand stack

A frame has two parts. The local variable array has numbered slots: slot 0 is the first parameter (args for main, this for an instance method), then the other parameters, then the locals. The operand stack is a scratch area: bytecode pushes values on it, works on them and stores them back. int a = 5; becomes iconst_5 (push 5) and istore_1 (pop into slot 1). The frame size is fixed when the method is compiled (max_locals, max_stack).

A call pushes a new frame and copies the arguments into its first slots. A return pops the frame: all its locals are gone in one move of the stack pointer, with nothing to free (Demo: primitives vs references).

Where objects go: TLABs and the bump pointer

New objects are made in the young generation's eden. To avoid a lock on every new, each thread gets a private piece of eden, a TLAB (thread-local allocation buffer). Allocating is then just "move my TLAB's top pointer forward by the object's size" — a few instructions. When the TLAB is full, the thread retires it (the unused tail gets a filler object) and takes a new one from eden. The red line in each row is the TLAB's top.

Eden split into main thread TLAB holding Counter 16 B and T2 TLAB; before, T2 TLAB is empty with its top at the start; after new Point(7, 7), the 24 B Point sits at the old top and top has moved forward by 24 B
Allocating in a TLAB is just moving the thread's own top pointer forward by the object size.
ObjectLayout (compressed oops)Size
new Object()mark word 8 + class pointer 4, padded16 B
Point(int x, int y)12 header + 4 + 4 = 20, padded to 824 B
Integer12 + 416 B
int[3]12 + 4 length + 3 × 4 = 28, padded32 B
Node(int val, Node next)12 + 4 + 4 (compressed reference)24 B

References are values: pass by value

Java always passes arguments by value. For an object, the value is the reference. In Demo: pass by value, move(p) gets a copy of p's reference: q.x = 9 follows the reference and changes the shared object, but q = new Point(0, 0) only changes move's own slot. After the call, p.x is 9 and the new point is garbage.

Three moments: after q.x = 9, move slot q and main slot p point to the same Point x = 9, y = 2; after q = new Point(0, 0), q points to the new Point and p still to x = 9; after move returns its frame is gone and the Point x = 0, y = 0 is garbage
Java copies the reference into the callee: changing the object is visible to the caller, reassigning the parameter is not.

Lifetime: reachability, not scope

A frame dies when its method returns. An object dies when nothing reaches it any more: the GC roots are the stack slots and operand stacks of every thread plus the static fields. In Demo: frames die, objects stay, makeList returns and its frame is gone, but its three Nodes stay alive because main's slot reaches them. After list = null nothing does, and the GC frees them. Grey objects on the canvas are unreachable; the JVM itself only finds out at the next GC.

A young GC copies the live young objects to the survivor space (or to the old generation when that is full) and empties eden at once: the cost depends on the live objects, not on the garbage. If the old generation has no room either, a full GC marks and compacts the whole heap. When even that frees nothing, new throws OutOfMemoryError: Java heap space (Demo: OutOfMemoryError: a static field keeps every chunk reachable). Java Garbage Collectors shows Parallel, G1 and ZGC in detail.

Two threads, one heap

Each thread has its own stack, so locals are never shared. Objects are: in Demo: two threads, one heap both threads hold a reference to the same Counter. c.n = c.n + 1 is three steps (read, add, write), so two threads can lose an update; that is what locks and atomics are for. Each thread also allocates in its own TLAB. How the CPU switches between the two stacks is on Linux Context Switch.

Boxing and the Integer cache

Integer a = 127; compiles to Integer.valueOf(127). Values from -128 to 127 come from a cache made at startup, so a == b is true. 128 is outside the cache: two separate 16 B objects, and c == d is false because == compares references (Demo: Integer cache). Compare boxed values with equals().

Stack allocation without the stack: escape analysis

Java has no way to put an object on the stack. The C2 JIT compiler can do something similar on its own: when escape analysis proves that an object never leaves a method (it is not stored in a field, not returned, not passed to code it cannot see), C2 applies scalar replacement: the object's fields become plain locals and the allocation disappears. In Demo: escape analysis the interpreter allocates three 24 B Points (72 B); the C2 version allocates 0 B. It is on by default (-XX:+DoEscapeAnalysis) and only applies to hot, compiled methods.

StackOverflowError

A thread's stack has a fixed size (-Xss, 1 MiB by default on 64-bit Linux). Below it lie guard pages (the yellow and red zones). A frame that would reach them makes the JVM throw StackOverflowError, an ordinary exception object on the heap: it unwinds the frames and can be caught (Demo: StackOverflowError). With 1 MiB a simple recursive method gets thousands of frames deep; the page scales the stack to 1 KiB.

C and Java side by side

C (Stack vs Heap in C)Java (this page)
Who frees heap memorythe programmer, free()the garbage collector
Pointer to a stack variablepossible (&local)impossible: objects are never on the stack
Dangling pointer, use-after-free, double freepossible, undefined behaviourimpossible
Memory leakforgotten freeforgotten reference (e.g. a static collection)
Heap allocation costmalloc: search a free listbump a TLAB pointer
Stack overflowSIGSEGV, the process diesStackOverflowError, can be caught
Per-object overhead8 B chunk header (glibc)12 B object header

See also Stack vs Heap in C, Java Garbage Collectors, How Linux Loads a Program (where a process's stack comes from), Linux Virtual Memory (both stack and heap are just mapped memory), Linux Context Switch and From a Java Thread to a CPU Core (which kernel task and CPU runs each thread).

What the page leaves out

Constructor frames (<init> runs in one step), object ages and tenuring thresholds, the card table, humongous objects, class metadata in Metaspace, the Thread and lambda objects of the threads demo, compiled frames and inlining, virtual threads (whose stacks are copied to the heap when they park), and -XX:-UseCompressedOops (8 B references, 16 B headers). The heap and stack capacities are scaled; real TLABs are kilobytes and real eden is megabytes.