Stack vs Heap in Java: Where Objects and Variables Live
Java keeps local variables in each thread's stack frames and every object on the shared heap. Aliasing, pass-by-value, object sizes and StackOverflowError.
- Course: Java study plan
- Module: Memory and the JVM runtime
- Kind: Lesson
- Reading time: 14 min
- Runtime: OpenJDK 18
What is the difference between stack and heap memory in Java?
Stack memory in Java belongs to one thread and holds a frame per method call — its local variables, parameters and references — pushed on call and popped on return. Heap memory is shared by all threads and holds every object and array created with new; it is sized by -Xmx and cleaned by the garbage collector. A reference on the stack points at an object on the heap.
Lesson
Every Java value lives in one of two places. Local variables, parameters and the bookkeeping of a method call live on a thread's stack, in a frame that is created on call and discarded on return. Objects — every new, every array, every string — live on the heap, shared by all threads and reclaimed by the garbage collector. A reference is a stack (or field) value that points at a heap object. Almost every "why did this happen" question about Java memory — aliasing, StackOverflowError, OutOfMemoryError, why passing an array to a method lets the method change it — is answered by drawing this picture correctly. This lesson draws it.
The call stack
Each thread has its own stack. A method call pushes a frame holding the method's local variables (including parameters and this), an operand stack for intermediate results, and a return address. The frame is fixed-size — the compiler computed how many slots the method needs — and it is popped in one step on return, which is why locals are "free": no allocation, no collection, no fragmentation.
static int area(int w, int h) { // frame: w, h, and a slot for the product
int a = w * h;
return a;
} // frame popped; a, w, h cease to exist
The stack is small — 512 KB to 1 MB per thread by default (-Xss), a few thousand to a few tens of thousands of frames. Recursion without a base case, or a legitimate recursion that is simply too deep (a linked list of a million nodes walked recursively), exhausts it:
static int depth(int n) { return depth(n + 1); } // StackOverflowError after ~10 000–20 000 frames
StackOverflowError is an Error, not an Exception: it means the program's structure is wrong, and you fix it by making the recursion iterative or bounded, not by catching it. Each thread's stack is its own, so a deep recursion in one thread does not affect another.
The heap
new allocates on the heap and returns a reference. The heap is shared by every thread, sized by -Xms (initial) and -Xmx (maximum), and managed by the collector, which finds objects nothing refers to and reclaims them (next two lessons). Objects never live on the stack in the language model — even a Point created and dropped inside one method is a heap object, although the JIT's escape analysis may quietly avoid the allocation when it can prove the object never leaves the method.
An object's memory is its header (12 bytes on a 64-bit JVM with compressed class pointers: a mark word for the identity hash, lock state and GC age, plus a pointer to its class) followed by its fields, padded to a multiple of 8. So new Object() is 16 bytes; an Integer is 16 bytes for 4 bytes of payload; an int[10] is 16 bytes of header-plus-length and 40 bytes of data, padded to 56. A String is two objects — the String and its byte[] — around 40 bytes before a single character. These numbers are why int[] beats List<Integer> by a factor of four or five in memory, and why "a million small objects" is where Java programs discover they have a memory budget.
References: the arrow, not the box
int[] a = {1, 2, 3};
int[] b = a; // b is a second arrow to the SAME array
b[0] = 99;
System.out.println(a[0]); // 99
a and b are two stack slots holding the same reference. There is one array. This is aliasing, and it is the single most common source of "my data changed and I did not change it" bugs: a getter that returns the internal array, a constructor that stores the caller's list, a static collection handed to two callers. int[] c = a.clone() or new ArrayList<>(list) makes a second object — a shallow copy, so the elements are still shared if they are themselves references.
Method calls copy the reference, not the object:
static void fill(int[] arr) { arr[0] = 7; } // changes the caller's array
static void swap(int[] arr) { arr = new int[]{0}; } // rebinds the local copy; the caller sees nothing
Java is strictly pass-by-value; the value happens to be an arrow. The method can follow the arrow and modify the object; it cannot make the caller's variable point somewhere else.
Primitives versus references
A primitive local (int, double, boolean, …) sits in the frame itself — the number is the slot. A reference local is a pointer to the heap. Fields follow the same rule inside the object: an int field is stored in the object; a String field is a pointer to another object. null is a reference that points nowhere; following it is a NullPointerException, whose helpful message since Java 14 names the variable that was null.
Boxing crosses the line: Integer x = 5 allocates an object (or fetches a cached one — lesson 4). An Integer[] is an array of pointers to sixteen-byte objects scattered across the heap; an int[] is a contiguous block of numbers. The cache behaviour of the CPU, not just the byte count, is why the primitive array is faster.
Two errors, two causes
| Error | Where | Why | Fix |
|---|---|---|---|
StackOverflowError | one thread's stack | recursion too deep | make it iterative, bound the depth, or raise -Xss (rarely right) |
OutOfMemoryError: Java heap space | the heap | live objects exceed -Xmx | find the leak or the oversized structure; raising -Xmx only delays a leak |
Neither should be caught in ordinary code. Both are Errors precisely because the program cannot sensibly continue.
Threads and the heap
Every thread sees the same heap, which is what makes shared data possible — and what makes the concurrency module necessary. The stack is private: a local variable can never be seen by another thread, so anything that lives only in locals is automatically thread-safe. "Keep it in a local" is the cheapest concurrency strategy there is.
Interview angle
- "Where are objects stored? Where are local variables?" Objects on the shared heap; locals and parameters in the thread's stack frame; a reference local is a pointer into the heap.
- "Is Java pass-by-reference?" No — pass-by-value; for objects the value copied is the reference, so the callee can mutate the object but cannot rebind the caller's variable.
- "What causes
StackOverflowError?" Frames exhausting the thread stack — usually unbounded recursion. - "How big is
new Object()?" About 16 bytes: a 12-byte header padded to 8-byte alignment. - "Why is
int[]so much smaller thanList<Integer>?" One contiguous block of 4-byte values versus an array of pointers to 16-byte objects.
Key takeaways
- Stack: per thread, frames of locals pushed on call and popped on return; small and fast.
- Heap: shared, holds every object and array, sized by
-Xmx, cleaned by the GC. - A reference is an arrow; copying it makes an alias, not a copy. Methods receive the arrow.
- Object header ≈ 12 bytes plus 8-byte alignment; boxing multiplies memory and scatters it.
StackOverflowError= recursion;OutOfMemoryError= too many live objects. Fix the cause, never catch.
Common questions
Is Java pass by value or pass by reference?
Java is always pass-by-value. For an object, the value copied into the method is the reference, so the method can modify the object the caller sees but cannot make the caller's variable point to a different object.
What causes StackOverflowError in Java?
StackOverflowError is thrown when method frames exhaust a thread's stack, usually through recursion with no base case or recursion that is simply too deep. The stack is 512 KB to 1 MB per thread by default (-Xss); make the recursion iterative or bounded rather than catching the error.
How much memory does an object take in Java?
On a 64-bit JVM with compressed class pointers an object header is 12 bytes, and every object is padded to a multiple of 8, so new Object() takes about 16 bytes. An Integer is also 16 bytes for 4 bytes of payload.
Why is an int array smaller than a List of Integer?
An int[] is one contiguous block of 4-byte values, while a List<Integer> holds pointers to separate 16-byte Integer objects scattered across the heap. The boxed list takes four or five times the memory and is slower because it defeats the CPU cache.
Are local variables thread-safe in Java?
Yes: each thread has its own stack, so a local variable can never be seen by another thread. The objects a local refers to live on the shared heap, though, and are only safe while no other thread holds a reference to them.
Exercises
Frames by hand
Simulate a thread's call stack with an ArrayDeque<String>. Read an integer n and n commands: call <name> pushes a frame and prints > <name> (depth <d>) where d is the stack size after the push; return pops the top frame and prints < <name>, or prints error: empty stack if there is nothing to pop. After all commands print maxDepth=<the deepest the stack got>.
Example: 5 then call main, call parse, return, call run, return →
> main (depth 1)
> parse (depth 2)
< parse
> run (depth 2)
< run
maxDepth=2Alias or copy?
Read an integer n and n integers into an array a; let b start as a copy of a (a.clone()). Then read an integer m and m commands: alias makes b refer to the same array as a; copy makes b a fresh clone of a; set <i> <v> assigns b[i] = v; show prints a=<Arrays.toString(a)> b=<Arrays.toString(b)>. Watch which assignments show through a.
Example: 3 then 1 2 3, then 6 then alias, set 0 9, show, copy, set 1 7, show →
a=[9, 2, 3] b=[9, 2, 3]
a=[9, 2, 3] b=[9, 7, 3]In this module: Memory and the JVM runtime
- Stack and heap — where every value lives (this lesson)
- Object lifecycle, reachability and reference types
- Garbage collection — generations, pauses and the collectors
- The string pool, interning and the Integer cache
- Class loading, initialisation order and the JIT
- Diagnosing memory problems — leaks, dumps and bounded caches
- Checkpoint — Memory and the JVM runtime
← Checkpoint — Streams · Object lifecycle, reachability and reference types →