An Accurate Mental Model of Java's Runtime Memory

Every line of running Java code keeps its data in one of two places with different lifetimes: the call stack, which holds what the current method needs, and the heap, which holds objects that can outlive any single call. Almost every ‘how did my variable change?’ or ‘why is my heap growing?’ question in Java traces back to the rules governing those two places. This post builds the model with four small programs — each one you can compile and run yourself — covering frames and local variables, heap allocation and constructors, pass-by-value semantics, and reachability-based garbage collection.

The stack: one frame per call

Start with the simplest question: when a method calls another, where do their variables live? The mental model to build is that each call creates a frame on the call stack, the frame holds that call’s parameters and local variables, and when the method returns, its frame is gone. A nested call pushes a new frame on top; control returns by popping.

This program prints its frame’s contents every time it enters a method, receives a return value, and leaves:

public class Stack {
    public static void main(String[] args) {
        int n = 42;
        System.out.println("enter main;  local n = " + n);
        int total = level1(10);
        System.out.println("back in main;  total = " + total);
        System.out.println("main frame still owns n = " + n + " and total = " + total);
    }

    static int level1(int x) {
        int doubled = x * 2;
        System.out.println("enter level1;  x = " + x + " | doubled = " + doubled);
        int y = level2(doubled);
        System.out.println("back in level1;  y = " + y + " | x still = " + x);
        return y + 1;
    }

    static int level2(int v) {
        int plus = v + 100;
        System.out.println("enter level2;  v = " + v + " | plus = " + plus);
        System.out.println("leaving level2;  returning " + plus);
        return plus;
    }
}

Compiling and running it (javac Stack.java && java Stack):

enter main;  local n = 42
enter level1;  x = 10 | doubled = 20
enter level2;  v = 20 | plus = 120
leaving level2;  returning 120
back in level1;  y = 120 | x still = 10
back in main;  total = 121
main frame still owns n = 42 and total = 121

Two things in that output are worth pausing on. The line back in level1; y = 120 | x still = 10 tells you that level1’s frame survived level2’s entire execution — x is still sitting in level1’s frame with its original value even after a deeper frame was pushed on top of it and popped back off. And the return value flows visibly frame by frame: 10 → doubled = 20 → plus = 120 → total = 121 is exactly ((10 * 2) + 100) + 1, with each step happening in the frame that owns that local. Each enter line is a push, each back in line a pop — the print order is the shape of the call stack.

The takeaway from this frame: local variables are temporary by construction. They exist exactly as long as the frame that declared them is on the stack. When a method returns, nothing about its locals persists.

The heap: objects live outside any frame

Now the second question: if locals vanish with their frame, where do objects go? The model is that new does three distinct things: it allocates an object in the heap, it runs the constructor exactly once, and it produces a reference — a small pointer-sized value — that some frame can store in a local variable. The object and the reference are separate things living in separate places.

To make the object’s identity visible, this program labels each constructed Point with System.identityHashCode — a per-object label — and tracks how many times the constructor runs:

public class Heap {
    static int constructions = 0;

    static class Point {
        final int x, y;
        Point(int x, int y) {
            constructions++;
            this.x = x;
            this.y = y;
            System.out.println("  >> Point() constructor ran #" + constructions
                + " -> identity " + System.identityHashCode(this));
        }
        String coords() { return "(" + x + ", " + y + ")"; }
    }

    static Point make(Point p) {
        System.out.println("  >> make() frame: reference p -> identity "
            + System.identityHashCode(p) + ", value " + p.coords());
        return p;
    }

    public static void main(String[] args) {
        System.out.println("main frame: creating object on the heap");
        Point p = new Point(3, 4);
        System.out.println("main frame: reference p -> identity "
            + System.identityHashCode(p) + ", value " + p.coords());

        Point q = make(p);
        System.out.println("same object? q identity = " + System.identityHashCode(q)
            + " == p identity = " + System.identityHashCode(p)
            + " -> " + (System.identityHashCode(q) == System.identityHashCode(p)));

        System.out.println("separate objects? new Point(3,4) == new Point(3,4) -> "
            + (new Point(3, 4) == new Point(3, 4)));
    }
}

Running it:

main frame: creating object on the heap
  >> Point() constructor ran #1 -> identity 705927765
main frame: reference p -> identity 705927765, value (3, 4)
  >> make() frame: reference p -> identity 705927765, value (3, 4)
same object? q identity = 705927765 == p identity = 705927765 -> true
  >> Point() constructor ran #2 -> identity 1175962212
  >> Point() constructor ran #3 -> identity 918221580
separate objects? new Point(3,4) == new Point(3,4) -> false

Notice that identity 705927765 appears in three different places — inside the constructor, in main’s frame, and in make’s frame — and the constructor counter says #1. One object, one construction, two frames each holding a reference to it. Passing p into make did not copy the object; it let another frame point at the same heap allocation.

Then compare the last two lines: two new Point(3, 4) expressions run the constructor twice (counters #2 and #3), produce two different identities, and == compares them as false. Equal field values, but == on object references is an identity comparison, not a value comparison — it asks whether two references point at the same object. That’s the whole reason equals() exists. So the updated model: new builds one object on the heap, and frames can hold any number of references to it, but references compare by where they point, not by what they point at.

Pass-by-value: a copy of a reference is still a copy

This is where the model earns its keep. A classic source of confusion: a method can change the contents of the object you passed in, but it can’t change which object your variable points at. Why? Because Java is strictly pass-by-value — including for object references. At the call site, the argument’s value (which, for an object, is a reference) is copied into the callee’s parameter, which is just another local variable in the callee’s frame. Both locals start out holding the same value; what happens to one local afterwards is unrelated to the other.

The program sets up exactly that contrast. increment mutates the object through its parameter; replace reassigns its parameter to a brand-new object. Both get the same argument:

public class PassByValue {

    static class Counter {
        int value = 0;
        Counter(int v) { value = v; }
        public String toString() { return "Counter(" + value + ")"; }
    }

    static void increment(Counter c) {
        System.out.println("  before: caller side sees " + c);
        c.value++;
        System.out.println("  after:  inside increment, c is " + c);
    }

    static void replace(Counter c) {
        System.out.println("  inside replace, before: caller side sees " + c);
        c = new Counter(999);
        System.out.println("  inside replace, after:  local c is now " + c);
    }

    public static void main(String[] args) {
        Counter counter = new Counter(10);
        System.out.println("main: counter = " + counter);

        System.out.println("call 1: increment(counter) -> mutate through the reference");
        increment(counter);
        System.out.println("back in main: counter = " + counter);

        System.out.println("call 2: replace(counter) -> reassign the parameter");
        replace(counter);
        System.out.println("back in main: counter = " + counter);

        System.out.println("identity check: new Counter(10) == new Counter(10) -> "
            + (new Counter(10) == new Counter(10)));
    }
}

Running it:

main: counter = Counter(10)
call 1: increment(counter) -> mutate through the reference
  before: caller side sees Counter(10)
  after:  inside increment, c is Counter(11)
back in main: counter = Counter(11)
call 2: replace(counter) -> reassign the parameter
  inside replace, before: caller side sees Counter(11)
  inside replace, after:  local c is now Counter(999)
back in main: counter = Counter(11)
identity check: new Counter(10) == new Counter(10) -> false

Call 1: inside increment, c.value++ writes through the reference — the parameter and main’s counter hold the same value (a pointer at the same object), so the write is visible when we’re back in main: Counter(11). Call 2: c = new Counter(999) doesn’t touch the object at all; it points increment’s local c at a different object. The 999 appears only inside the method; main still reads Counter(11). Rebinding a parameter is exactly like rebinding any other local — it dies with the frame.

So the one-sentence rule: mutation goes through the shared object; rebinding stays in the local. If you want a method to rebind your reference, you’d have to pass something mutable — a wrapper object, an array, or a functional interface — and mutate its contents, for the same reason call 1 worked.

Garbage collection: reachability decides fate

The heap solves the lifetime problem for objects — they outlive the frames that created them. The price is that someone has to decide when an object is finally dead. Java’s answer: an object is live as long as it’s reachable — as long as you can get to it by following references from a root such as a live local variable or a static field. The garbage collector’s job is to find what’s unreachable and reclaim it. Most modern collectors are generational: the heap is split into a young generation where new objects land and an older one, and collections focus on the young generation first, on the well-established observation that most objects die young. (Whether a particular collection runs, and when, is the collector’s business — your code just allocates and drops references.)

That reachability rule also defines what a memory leak is in Java: not a bug in the collector, but an object that is collectable in principle yet stays reachable in practice because some reference to it has been kept — the heap grows, and that space is not given back.

The program demonstrates both sides under a hard 256 MiB heap cap (-Xmx256m). The leak loop allocates a 10 MiB buffer each iteration and hands it to a static List, which keeps holding every one of them. The fixed loop is identical except the list has been cleared, so each buffer stops being reachable at the end of its iteration. (System.gc() is a request, not a command — per the Java Language Specification, an implementation may ignore it — so the program doesn’t rely on it actually running.)

public class Gc {
    static final long BUF = 10L * 1024 * 1024; // 10 MiB per buffer

    static class Holder {
        static final java.util.List<byte[]> list = new java.util.ArrayList<>();
        static void add(byte[] b) { list.add(b); }
        static void clear() { list.clear(); }
    }

    public static void main(String[] args) {
        System.out.println("== leak: every buffer is held by the static list ==");
        int leaked = 0;
        try {
            for (int i = 0; i < 1000; i++) {
                byte[] buffer = new byte[(int) BUF]; // local ref dies each iteration
                Holder.add(buffer);                  // ...but the static list keeps it
                leaked++;
            }
        } catch (OutOfMemoryError oom) {
            System.out.println("  stopped after " + leaked + " buffers: " + oom);
        }

        System.out.println();
        System.out.println("== fixed: nothing else holds the buffers ==");
        Holder.clear();
        int produced = 0;
        try {
            for (int i = 0; i < 300; i++) {
                byte[] buffer = new byte[(int) BUF]; // dies at the end of each iteration
                System.gc();                         // request a collection; JVM may honor it
                produced++;
            }
            System.out.println("  finished all " + produced + " buffers");
        } catch (OutOfMemoryError oom) {
            System.out.println("  stopped at #" + produced + ": " + oom);
        }
    }
}

Running it with javac Gc.java && java -Xmx256m Gc:

== leak: every buffer is held by the static list ==
  stopped after 23 buffers: java.lang.OutOfMemoryError: Java heap space

== fixed: nothing else holds the buffers ==
  finished all 300 buffers

Both loops execute the exact same allocation, line for line. The only difference is whether a second reference is retained. In the leak loop, the local buffer reference dies at the end of every iteration, but the static list’s reference lives for the whole program — so all 23 buffers (about 230 MiB, plus whatever else the JVM needs in a 256 MiB heap) stayed reachable simultaneously, and the allocation that couldn’t fit threw OutOfMemoryError. The fixed loop completed 300 allocations of 10 MiB each — 3 GiB of cumulative allocation inside a heap capped at 256 MiB. That can only happen if each buffer became unreachable the moment its iteration ended and the collector reclaimed it: at any moment, one buffer was live, not 300.

This is the leak in its purest form, and it’s worth remembering how un-dramatic it is: no crash during normal operation, no warning, just a program that allocates into a growing reachable set until the day the heap runs out. The collector cannot reclaim an object while something still holds a reference to it — reachability is the whole test.

Takeaway

Hold four rules and you can predict most of Java’s memory behavior: locals live as long as their call frame; objects live on the heap as long as some reference can reach them; references are ordinary values, so they’re passed by copy — which is why a method can mutate an object’s contents but cannot rebind your variable; and garbage collection reclaims what’s unreachable, so a ‘leak’ is simply a reference you forgot to drop.