stack and heap
where your variables live vs where your objects live. the most important picture in java.
- chapter
- chapter 02.11
- reading time
- 8 min read
every method call gets a frame on the stack for its local variables. every object lives on the heap. a variable of an object type holds a reference, an arrow to the object, never the object itself.
that's the whole chapter. the rest is pictures.
the picture
you know how int b = a makes a copy, but Point q = p somehow doesn't? that's because there are two kinds of memory in play, and the variable only ever holds what fits in its own little slot.
think of a coat check. the heap is the cloakroom (where the coats actually are). a reference is the ticket in your pocket. you can photocopy the ticket as many times as you want and there's still one coat. lose every ticket and the coat just sits there until staff clears it out (that's the garbage collector).
notice age appears twice and means two different things. the local age (21) sits in the frame. the field age (36) sits inside the object, on the heap, because fields are part of the object no matter what type they are. and name isn't text sitting in the Person, it's another arrow, because String is an object too.
walk through it
press step (or click the figure and use ← →). watch what = actually copies each time.
- 1class Point {
- 2 int x, y;
- 3 Point(int x, int y) { this.x = x; this.y = y; }
- 4}
- 5
- 6void main() {
- 7 int a = 7;
- 8 int b = a;
- 9 b = 9;
- 10 Point p = new Point(1, 2);
- 11 Point q = p;
- 12 q.x = 100;
- 13 p = new Point(5, 5);
- 14 q = null;
- 15 IO.println(a + " " + b + " " + p.x);
- 16}
·main gets a frame on the stack. nothing in it yet.
see? b = a copies a number. q = p copies an arrow. same =, same rule (copy what's in the slot), wildly different vibes because of what's in the slot.
int[] a = {1, 2, 3};
int[] b = a;
b[0] = 99;
IO.println(a[0]);String s = "hi";
String t = s;
t = t + "!";
IO.println(s);what lives where
| thing | where it lives |
|---|---|
a local primitive (int n = 3) | in the frame, on the stack |
a local reference (Point p) | the reference is in the frame. the object is on the heap |
| every object, every array | the heap. always. no exceptions in the language |
fields, even int ones | inside their object, so on the heap |
static fields | with the class, which the JVM keeps on the heap too |
| method parameters | in the callee's frame, as copies (more in pass-by-value) |
| string literals | on the heap, deduplicated in the string pool |
why two kinds of memory
because they die differently.
a frame dies the instant its method returns. the JVM doesn't clean up anything, it just moves a pointer back down, so allocating and freeing locals is basically free. but that only works because nothing outlives the call.
objects need to outlive the method that made them (you return them, store them in lists, hand them to other threads). so they go somewhere that doesn't care about calls: the heap. the price is that someone has to figure out when an object is actually dead. that someone is the garbage collector.
when the stack runs out
a stack is a fixed-size block (set with -Xss). every call pushes a frame, so recursion without a base case eventually runs out of room.
public class Main {
static int calls = 0;
static void down() { calls++; down(); }
public static void main(String[] args) {
try {
down();
} catch (StackOverflowError e) {
System.out.println("overflowed after " + calls + " calls");
}
}
}1,200 frames of down() on top of main().
room for ~45,424 more.why the same program overflows at different depths+
run that program twice in a row inside the same JVM and the second run goes deeper. on jdk 25 with -Xss1m it was 20,773 calls cold and 57,948 once the JIT had compiled down().
interpreted frames are generous: they carry the full local array, an operand stack and bookkeeping for the interpreter. compiled frames only keep what the optimized code needs. same method, smaller frames, more of them fit. so "how deep can i recurse" has no single answer, which is a good reason to never design code that depends on it.
the heap can run out too. that's OutOfMemoryError: Java heap space, and it's a whole different story (spoiler: usually a leak). see the OOM zoo.
objects that never touch the heap+
"every object lives on the heap" is the language's promise. the JIT is allowed to cheat as long as you can't tell.
if escape analysis proves an object never leaves the method that created it (never returned, never stored in a field, never passed somewhere that keeps it), C2 can skip the allocation entirely and keep the fields in registers. it's called scalar replacement. your code still looks like it allocated a Point, you just never paid for it. more in JIT optimizations.
- every call gets a frame. the frame dies when the call returns.
- objects live on the heap. variables hold arrows to them.
=always copies what's in the slot. for objects, that's the arrow.- two arrows to one object means changes through one show up through the other.
- an object with no arrows left is garbage. the GC decides when to clean it up.