Stack, heap and references
Where a local lives, where an object lives, what a reference is, and why passing an object "by reference" is a misreading.
Most confusion about Java — "is it pass-by-reference?", "why did my change not stick?", "why is this null?" — comes from not having a picture of where things live. Two regions matter for now: the stack and the heap. Draw them once and the rest of the language gets simpler. This lesson draws them, then goes one level down: what a frame holds, what an object costs in bytes, how allocation is nearly free, and why a reference is a value and not a pointer you can do arithmetic on.
Every thread has a stack
When a method is called, the JVM pushes a frame onto the calling thread's stack. The frame holds that call's local variables and its operand stack (the JVM's scratch space). When the method returns, the frame is popped and everything in it is gone. No garbage collector is involved; that is what makes local variables cheap.
A local variable of a primitive type — int total, double rate, boolean done — holds its value in the frame. A local variable of any other type holds a reference: a value that says where, on the heap, the object is.
void example() {
int count = 3; // 3 lives in this frame
List<String> names = new ArrayList<>(); // the ArrayList lives on the heap;
// `names` in this frame refers to it
}Under the hood: what a frame holds
javap -v shows the two numbers every method carries: stack=2, locals=3. The JVM sizes the frame from them before the method runs, so a frame is a fixed-size block and pushing one is a pointer bump:
| Part | Holds |
|---|---|
| local variable array | slot 0 is this (or the first parameter of a static method), then parameters, then locals, in declaration order; a long or double takes two slots |
| operand stack | the JVM is a stack machine: iload_1; iload_2; iadd pushes two locals and replaces them with their sum; max stack is the deepest it gets |
| frame data | a reference to the class's constant pool, and where to return to |
Locals have no names at run time; total is "slot 1". The LocalVariableTable attribute keeps the names for the debugger, and a build with -g:none drops it. After the JIT compiles the method none of this is literal anymore: locals become registers and the operand stack disappears. The frame model is the specification of the behaviour, and the interpreter implements it literally.
Objects live on the heap
Every new allocates on the heap — the region shared by all threads, managed by the garbage collector. The object stays there for as long as anything can reach it: a local in some live frame, a static field, another reachable object's field. When nothing reaches it, it is garbage and will be collected eventually. Arrays are objects too, so int[] a = new int[10] puts ten ints on the heap and a reference in the frame. String s = "hello" is a reference to a String object (interned, but on the heap nonetheless).
What an object costs, on a 64-bit JVM with compressed class pointers (the default under 32 GB heaps):
| Bytes | |
|---|---|
| header: mark word (identity hash, lock state, GC age) | 8 |
| header: class pointer | 4 |
| fields, each aligned to its size; a reference is 4 with compressed oops | … |
| padding to a multiple of 8 | 0–7 |
So an empty object is 16 bytes, an Integer is 16 (12 + 4), a Point(int x, int y) is 24 (12 + 8, padded to 24... 12 + 8 = 20, padded to 24), and an ArrayList with its size and elementData fields is 24 plus its backing array. jol (Java Object Layout) prints this for any class, and it is the tool to reach for when a heap dump says a hundred million of something.
Allocation is cheaper than people expect. Each thread has a thread-local allocation buffer carved out of the young generation; new is a pointer bump inside it, about ten instructions, with no lock. The cost of an object is not creating it but keeping it: every live object is traced by the collector, and every dead one is reclaimed by copying its live neighbours out of the way. The JVM Internals course covers the collectors; here it is enough that short-lived objects are nearly free and long-lived ones are what you pay for.
And a third place: metaspace
Stack and heap are the two every interview asks about. There is a third, and on Java 8 it is new.
A class itself has to live somewhere — its bytecode, its field and method tables, its constant pool, the Class object's metadata. Before Java 8 that was the permanent generation, a fixed region carved out of the heap, and running out of it produced the error a generation of Java developers can still recite:
java.lang.OutOfMemoryError: PermGen spaceJava 8 removed it (JEP 122). The JVM says so if you try to size it:
$ java -XX:PermSize=64m -version
OpenJDK 64-Bit Server VM warning: ignoring option PermSize=64m;
support was removed in 8.0Class metadata now lives in metaspace, and the difference is not the name. Metaspace is allocated from native memory, outside the heap:
| PermGen (≤7) | Metaspace (8+) | |
|---|---|---|
| Where | inside the heap | native memory |
| Default limit | fixed, small, tuned with -XX:MaxPermSize | unlimited |
Bounded by -Xmx? | yes | no |
| First GC trigger | when the region fills | MetaspaceSize, about 20 MB |
Three consequences a backend engineer actually meets:
-Xmxno longer bounds your process. A container with a 512 MB limit and-Xmx400mcan still be killed by the kernel's OOM killer while the heap sits at 200 MB, because metaspace grew underneath it. The heap dump shows nothing wrong, which is what makes it hard.- Classloader leaks changed shape, not existence. Redeploying a web app repeatedly used to exhaust PermGen; now it exhausts metaspace and the machine's memory with it. Every loaded classloader holds its classes alive.
-XX:MaxMetaspaceSizeis how you make that failure loud. Set it, and you getOutOfMemoryError: Metaspace— a Java error with a stack trace — instead of a process that vanishes with exit code 137 and no explanation.
Frameworks that generate classes at run time are the usual cause: Spring's CGLIB proxies, Hibernate's bytecode enhancement, anything using a mocking library in a long-lived JVM. A few thousand generated classes is normal; a few hundred thousand is a leak.
Java is pass-by-value. Always.
Here is the sentence to memorise: a method receives a copy of each argument's value, and for an object the value is the reference.
static void rename(Person p) {
p.name = "Beth"; // follows the reference, mutates the shared object
p = new Person("Carl"); // reassigns the LOCAL copy of the reference
}
Person a = new Person("Ann");
rename(a);
System.out.println(a.name); // BethLine one changed the object both a and p refer to, so the caller sees it. Line two pointed the parameter somewhere else; the caller's a still refers to Beth. If Java were pass-by-reference, a would now be Carl. It is not.
This is the whole answer to "why did my change not stick": you reassigned a parameter, which is a local variable, instead of mutating the object it referred to. The corollary — "why did my change stick when I did not want it to" — is the same picture from the other side: you mutated a shared object.
caller. A List is created on the heap. The caller's frame holds a reference to it -- four bytes that are a pointer, not the list.
call. The reference is COPIED into the callee's frame. Two variables, in two frames, now hold the same four bytes.
param.add(x). The callee follows its copy of the reference and mutates the object at the other end. There is only one object.
param = new. The callee assigns a different reference to its own variable. It has overwritten its COPY. Nothing the caller can see has changed.
return. The callee's frame is popped and its variable is gone. The caller still points at List@A, which still has x in it.
Walkthrough: the frame, instruction by instruction
Take rename above and watch the frames. Slot 0 of rename's frame is p, a copy of the reference in a.
mainexecutesnew Person("Ann"): the JVM bumps the TLAB pointer, writes a header and anamefield, and pushes the reference;astoreputs it ina's slot.invokestatic rename: a new frame; the reference is copied frommain's operand stack intorename's slot 0. Two slots, one object.p.name = "Beth"compiles toaload_0; ldc "Beth"; putfield Person.name: load the reference from slot 0, follow it to the heap, write the field.main'sapoints at the same bytes, so it sees the change.p = new Person("Carl")compiles tonew; dup; ldc; invokespecial <init>; astore_0: allocate a second object, store its reference in slot 0 of this frame.main's slot is untouched.returnpops the frame. The Carl object is now reachable from nowhere and is garbage. Beth lives on througha.
Every "is it by reference" argument dissolves at step 4: the store went to a slot in the callee's frame, and slots are not shared.
null is the absence of a reference
A reference variable can hold null: it refers to nothing. Dereferencing it — calling a method, reading a field, indexing an array — throws NullPointerException. Since Java 14 (JEP 358) the message tells you what was null, computed from the bytecode that faulted:
Cannot invoke "String.length()" because "this.name" is null
Cannot read field "street" because the return value of "Customer.address()" is nullThe second form is the one that makes a chained expression debuggable: it names which link in customer.address().street() was missing. Primitives cannot be null; there is no "absence" of an int. This is the origin of a family of bugs covered in the next lesson, where an Integer field is unboxed into an int and the NPE appears three layers away from the field.
Reading a stack trace
A stack trace is the thread's stack, printed top to bottom, innermost frame first:
java.lang.IllegalArgumentException: amount must be positive
at com.shop.billing.Invoice.addLine(Invoice.java:42)
at com.shop.billing.InvoiceService.create(InvoiceService.java:88)
at com.shop.api.InvoiceController.post(InvoiceController.java:31)
at jdk.internal.reflect.DirectMethodHandleAccessor.invoke(...)
at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(...)
... 87 moreRead it like this: the first line is what went wrong; the first frame in your package is where; the frames above it (there are none here) are library code that threw; the frames below are how execution got there. The ... 87 more are frames shared with an enclosing exception, when one exception wraps another. The line numbers come from the LineNumberTable attribute javac wrote into the class file, which is why a jar built without debug info shows Unknown Source. A frame that says Native Method is C code inside the JVM, and a frame with a $$Lambda or $Proxy name is generated code: read past it to the next frame you wrote.
Stack size and StackOverflowError
Each thread's stack is fixed in size, set with -Xss. The default depends on the architecture, not just the word size: 1 MB on x86_64 Linux, 2 MB on aarch64 — which is most of the cloud now, between Graviton, Ampere and Apple Silicon. Code that recursed safely in CI on ARM can overflow on x86 at half the depth. java -XX:+PrintFlagsFinal -version | grep ThreadStackSize prints the number rather than guessing it. Deep recursion, or a very deep call chain, overflows it: StackOverflowError. It is an Error, not an Exception, which is the JVM's way of saying "do not catch this and carry on". The usual cause is unbounded recursion — an equals that calls itself through a cycle, a toString that prints a parent that prints the child, a JSON serialiser walking a bidirectional JPA relationship.
A frame is not one size, and the difference is the JIT. The same recursion, five runs each on a 1 MB stack:
with the JIT -Xint (interpreter only)
21,358 49 b/frame 11,834 88 b/frame
11,834 88 b/frame 11,834 88 b/frame
21,716 48 b/frame 11,834 88 b/frame
21,870 47 b/frame 11,834 88 b/frame
21,204 49 b/frame 11,834 88 b/frameAn interpreted frame is about 88 bytes; a compiled one is about half that, because the JIT keeps in registers what the interpreter keeps in slots. So a megabyte is ten thousand frames of cold code and twenty thousand of hot code.
Look at the second run in the left column: exactly 11,834, the interpreted number. That run overflowed before HotSpot got round to compiling the method. "How deep can I recurse" has no single answer — it is a race between your recursion and the compiler.
A Spring request already sits on a hundred frames before your code runs. Virtual threads (Java 21) change one thing here: their stacks live on the heap as growable chunks and are copied to a carrier thread's stack only while running, which is why a million of them fit and a million platform threads do not.
Try it yourself
What prints?
static void touch(int[] a, int n) { a[0] = 99; n = 99; a = new int[]{7}; }
int[] arr = {1, 2}; int k = 1;
touch(arr, k);
System.out.println(arr[0] + " " + k + " " + arr.length);Answer
99 1 2. a[0] = 99 writes through the copied reference into the shared array. n = 99 changes a slot in touch's frame; k in the caller is untouched. a = new int[]{7} repoints the callee's slot at a new array, which is garbage the moment touch returns. The caller's arr still refers to the original two-element array.
Count the bytes
How much heap does new ArrayList<Integer>(Arrays.asList(1, 2, 3)) take, counting every object it creates, on a JVM with compressed oops? (The Arrays.asList view is a temporary you may ignore. Look up ArrayList's fields before you answer — including the ones it inherits.)
Answer
Three parts. The ArrayList object has three instance fields, not two: elementData and size of its own, plus modCount inherited from AbstractList. So 12-byte header + 4 + 4 + 4 = 24 exactly, with no padding at all. Forgetting an inherited field and then reaching for padding to make the number land is the commonest way to get an object's size wrong — measured, an ArrayList shell is 24.0 bytes. The backing Object[3]: 16-byte array header + 3 × 4 = 28, padded to 32. Three Integers, but 1, 2 and 3 are in the Integer cache, so 0 new bytes for them; with values above 127 it would be 3 × 16 = 48 more. Total 56 bytes for three small numbers, against 12 for an int[3]'s payload. That ratio is the whole argument for primitive arrays and IntStream in the next lesson.
Find the frame
java.lang.NullPointerException: Cannot invoke "java.util.List.size()" because the return value of "com.shop.Cart.lines()" is null
at com.shop.CartTotals.count(CartTotals.java:18)
at com.shop.CheckoutService.summarise(CheckoutService.java:52)Which line do you open first, and what do you already know before opening it?
Answer
CartTotals.java:18, the innermost frame in your package, is where .size() was called on the null. You already know the null is the return value of Cart.lines(), not a field or a local at line 18, so the question is why lines() returned null for this cart: a constructor that never initialised the list, or a deserialiser that left it absent. The fix is usually in Cart, not at line 18, and it is usually "return Collections.emptyList(), never null".
Misconceptions
- "Objects are passed by reference." A reference is passed by value. The observable difference is line two of
rename: reassigning the parameter never reaches the caller. - "Primitives are on the stack, objects on the heap." Primitive locals are in the frame; a primitive field is inside its object on the heap; an object that never escapes may, after escape analysis, never touch the heap at all. Location follows lifetime, not type.
- "Allocation is expensive, so reuse objects." Allocation is a pointer bump; keeping objects alive is what costs. Pooling short-lived objects usually makes a program slower and its garbage older.
- "A reference is a pointer." It locates an object, but you cannot read its bits, add to it, or cast it to an integer; the collector moves objects and rewrites references behind your back. That is what makes compaction and compressed oops possible.
- "
StackOverflowErrormeans the stack is too small." Once in a thousand times. The other times it is unbounded recursion, and raising-Xssdelays the crash without fixing it.
Going deeper
- The JVM Specification §2.5 and §2.6: run-time data areas and frames, in a few pages.
- JEP 358, Helpful NullPointerExceptions: how the message is derived from bytecode.
jol(OpenJDK's Java Object Layout):ClassLayout.parseClass(Foo.class).toPrintable().- Aleksey Shipilëv, "JVM Anatomy Quark #4: TLAB allocation" for why
newis ten instructions. - JEP 444, Virtual Threads, §"Stack chunks" for where their frames live.