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.

16 min read Java Fundamentals

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.

java
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:

PartHolds
local variable arrayslot 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 stackthe 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 dataa 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.

stack (thread main) example() count = 3names = ● main()args = ● pushed on call, popped on return; no GC heap (shared, collected) ArrayList header · 12 B size = 0 · elementData = ● Object[10]16 B + 40 B reachable from a frame → alive reachable from nothing → garbage
The value in the frame is the arrow, not the box. Copy the arrow and two names point at one object; drop every arrow and the object is garbage.

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 pointer4
fields, each aligned to its size; a reference is 4 with compressed oops
padding to a multiple of 80–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:

plaintext
java.lang.OutOfMemoryError: PermGen space

Java 8 removed it (JEP 122). The JVM says so if you try to size it:

the flag is goneplaintext
$ java -XX:PermSize=64m -version
OpenJDK 64-Bit Server VM warning: ignoring option PermSize=64m;
support was removed in 8.0

Class 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+)
Whereinside the heapnative memory
Default limitfixed, small, tuned with -XX:MaxPermSizeunlimited
Bounded by -Xmx?yesno
First GC triggerwhen the region fillsMetaspaceSize, about 20 MB

Three consequences a backend engineer actually meets:

  • -Xmx no longer bounds your process. A container with a 512 MB limit and -Xmx400m can 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:MaxMetaspaceSize is how you make that failure loud. Set it, and you get OutOfMemoryError: 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.

java
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);  // Beth

Line 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.

callercallparam.add(x)param = newreturn
caller's var-> List@Acallee's paramdoes not existList@A[ ]

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.

1 / 5

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.

  1. main executes new Person("Ann"): the JVM bumps the TLAB pointer, writes a header and a name field, and pushes the reference; astore puts it in a's slot.
  2. invokestatic rename: a new frame; the reference is copied from main's operand stack into rename's slot 0. Two slots, one object.
  3. p.name = "Beth" compiles to aload_0; ldc "Beth"; putfield Person.name: load the reference from slot 0, follow it to the heap, write the field. main's a points at the same bytes, so it sees the change.
  4. p = new Person("Carl") compiles to new; dup; ldc; invokespecial <init>; astore_0: allocate a second object, store its reference in slot 0 of this frame. main's slot is untouched.
  5. return pops the frame. The Carl object is now reachable from nowhere and is garbage. Beth lives on through a.

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:

plaintext
Cannot invoke "String.length()" because "this.name" is null
Cannot read field "street" because the return value of "Customer.address()" is null

The 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:

plaintext
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 more

Read 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:

frames reached before StackOverflowErrorplaintext
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/frame

An 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?

java
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

plaintext
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.
  • "StackOverflowError means the stack is too small." Once in a thousand times. The other times it is unbounded recursion, and raising -Xss delays 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 new is ten instructions.
  • JEP 444, Virtual Threads, §"Stack chunks" for where their frames live.
Progress is saved on this device and to your account when signed in.