What lives on the heap and what lives on the stack?
Objects go on the heap, shared by every thread and collected by the GC. Frames go on the stack, one per thread, popped on return. The rule everyone recites — primitives on the stack, objects on the heap — holds only for locals: an int field lives inside its object, on the heap. And it is not absolute, because escape analysis deletes allocations the JIT proves never escape.
The Answer
- Heap — every object, every array, and every instance field they contain. One heap, shared by all threads, managed by the garbage collector.
- Stack — one per thread, holding a frame per call. A frame holds the local variables, the operand stack and the return address. It is popped on return, so nothing there is ever garbage collected.
- A local of primitive type lives in the frame. A local of reference type holds a reference in the frame; the object it points at is on the heap.
- An instance field of primitive type is not on the stack. It lives inside its object, on the heap. This is where the usual one-liner breaks.
- Run out of frames and you get
StackOverflowError; run out of heap and you getOutOfMemoryError. Different regions, different fixes —-Xssversus-Xmx. - And the split is not absolute: HotSpot's escape analysis can prove an object never leaves its method and delete the allocation entirely.
Understand It
The three things a reference-typed local actually involves
Take Counter c = new Counter(). That is a reference in the frame, an object on the heap, and a field inside the object. Copy the variable and you copy only the first of the three:
class Counter { int value; }
Counter a = new Counter();
Counter b = a; // a second reference to ONE object
b.value = 42;
System.out.println("a.value after writing through b : " + a.value);
System.out.println("same object : " + (a == b));
int local = 7; // a primitive LOCAL, in the frame
int copy = local;
copy = 42;
System.out.println("local after copy = 42 : " + local);
int[] numbers = {1, 2, 3};
System.out.println("int[] is an object : " + (numbers instanceof Object));
System.out.println("its runtime class : " + numbers.getClass().getName());a.value after writing through b : 42
same object : true
local after copy = 42 : 7
int[] is an object : true
its runtime class : [IThree facts fall out of those five lines:
b.value = 42is visible througha, so there is oneintand it is inside the shared object. Thatintis a primitive on the heap, which the tutorial rule says cannot exist.copy = 42is invisible tolocal, because those really are two slots in the frame.int[]is an object with a class —[Iis the JVM's name for it — so an array of primitives is a heap object too, no matter what it holds.
The accurate rule is short: locals live in the frame; everything reachable from a reference lives on the heap. Whether it is a primitive has nothing to do with it.
Stacks are per thread, and they have a size you can see
There is one heap. There are as many stacks as there are threads, and each is a fixed reservation made when the thread starts. You can watch the size change the answer:
System.out.println("depth on a 256 KB stack : " + depthOnStackOf(256 * 1024));
System.out.println("depth on a 1 MB stack : " + depthOnStackOf(1024 * 1024));
System.out.println("depth on a 8 MB stack : " + depthOnStackOf(8 * 1024 * 1024));depth on a 256 KB stack : 1794
depth on a 1 MB stack : 23608
depth on a 8 MB stack : 517772Run it three times and you will get three different sets of numbers, which is itself worth understanding. More stack always buys more depth, but not proportionally: a frame's size depends on how many locals the method has and on whether the JIT has compiled it yet, and a compiled frame is smaller than an interpreted one. On the 8 MB probe the recursion runs long enough for C2 to compile it partway through, so the last run of this exact code reached 207,110 and the one before it 517,772 — same program, same machine, seconds apart.
The honest takeaway is not a number. It is that stack depth is a property of the thread, not of the program.
This is why "increase -Xss" is a real answer to a StackOverflowError in a deeply recursive parser, and a bad answer to one in an accidentally infinite recursion. One buys you a proportional amount of headroom; the other just makes the crash slower.
It is also why thread count used to be capped in the low thousands. Each platform thread reserves its stack — typically 1 MB — whether or not it uses it. Java 21+A virtual thread's stack lives on the heap instead, as a chunked continuation that grows on demand, which is the whole reason a million of them is affordable.
The two errors, and why confusing them wastes a day
try {
long[] huge = new long[Integer.MAX_VALUE - 1];
System.out.println(huge.length);
} catch (OutOfMemoryError e) {
System.out.println("heap : " + e.getClass().getSimpleName() + " — " + e.getMessage());
}
try {
descend();
} catch (StackOverflowError e) {
System.out.println("stack : " + e.getClass().getSimpleName() + " — message is " + e.getMessage());
}heap : OutOfMemoryError — Requested array size exceeds VM limit
stack : StackOverflowError — message is nullBoth are Error, not Exception, so neither is something you plan to catch. The distinction that matters in an incident is which region ran out:
StackOverflowError | OutOfMemoryError | |
|---|---|---|
| Region | one thread's stack | the shared heap (or Metaspace, or native) |
| Typical cause | unbounded recursion; occasionally genuine depth | a leak, an unbounded cache, or one enormous allocation |
| Knob | -Xss | -Xmx |
| Blast radius | the thread that overflowed | usually the whole JVM |
| Diagnostic | the stack trace itself — look for a repeating cycle | a heap dump, -XX:+HeapDumpOnOutOfMemoryError |
Note the message on the first one. Requested array size exceeds VM limit is not "your heap is too small" — it means the array length itself is beyond what the JVM can address, and no -Xmx on earth fixes it. An actual heap exhaustion says Java heap space instead. Reading which of those two you got saves you from resizing a container that was never the problem.
Escape analysis, or why "every object is on the heap" is out of date
This is the part almost nobody demonstrates, so here is the measurement. Ten million Point objects, each used and immediately discarded, with the JIT given a warm-up first. allocatedBytes() reads HotSpot's per-thread allocation counter, which counts real heap bytes:
sumOfPoints(200_000); // let C2 compile the loop first
long before = allocatedBytes();
int checksum = sumOfPoints(10_000_000);
long after = allocatedBytes();
System.out.println("checksum : " + checksum);
System.out.println("bytes allocated for 10m Points : " + (after - before));
System.out.println("bytes if each were allocated : " + (10_000_000L * 24)); // header + 2 ints, alignedchecksum : 276447232
bytes allocated for 10m Points : 42240
bytes if each were allocated : 240000000Ten million objects. Forty-two kilobytes — and the figure wanders between about 28 KB and 45 KB from run to run, because what you are measuring is the handful of interpreted iterations before C2 catches up, not the compiled ones. Either way it is not a smaller heap. It is almost no heap at all.
C2 proved that no Point escapes sumOfPoints: none is stored in a field, returned, or passed anywhere that could keep it. Having proved that, it applies scalar replacement — the object is dismantled into its two int components, which live in registers, and the allocation never happens. There is nothing for the garbage collector to collect because nothing was created.
Turn the optimisation off and the same program allocates all 240 MB — 24 bytes per Point, being a 12-byte header plus two ints, rounded up to the 8-byte alignment:
$ java Points # 42,240 bytes
$ java -XX:-DoEscapeAnalysis Points # 240,000,000 bytes — optimisation disabled
$ java -Xint Points # 240,000,000 bytes — interpreter only, never compiled
Those are measured, not estimated, and the second and third lines agreeing is the tell: escape analysis is something C2 does, so a method the JIT never compiled never gets it.
Two consequences worth carrying into an interview. First, "avoid creating objects in a loop" is advice from a JVM that no longer exists, for objects that do not escape. Second, this only happens after the JIT compiles the method, which is why a microbenchmark that skips warm-up measures the interpreter and reports the opposite conclusion.
The caveat that keeps this honest: escape analysis is a compiler optimisation, not a language guarantee. It is not in the JVM specification, it can bail out on a method that is too large to inline, and it disappears the moment the object is stored anywhere that outlives the call. Design for correctness; take the allocation win as a gift.
Reference
Where each thing lives
| Declaration | The reference/value | The object |
|---|---|---|
int i = 5; (local) | frame slot | — |
long l = 5L; (local) | two frame slots | — |
String s = "x"; (local) | frame slot | heap, and the literal is interned |
int[] a = new int[10]; (local) | frame slot | heap |
int count; (instance field) | — | inside the object, on the heap |
static int total; (static field) | — | on the heap, inside the java.lang.Class object — see below |
class Foo {} (its metadata) | — | Metaspace (native memory) before Java 8PermGen, inside the heap |
| a captured local in a lambda | copied into the lambda object | heap |
The flags, and what each one actually moves
# Heap. The two you will set most often.
-Xms512m -Xmx2g # initial and maximum heap
-XX:MaxRAMPercentage=75.0 # prefer this in a container — -Xmx hardcodes a
# number that survives every resize of the pod
# Stack. Per thread, applied to threads that do not ask for their own.
-Xss1m # default is platform-dependent, ~1 MB on 64-bit Linux
# Class metadata, outside the heap since 8.
-XX:MaxMetaspaceSize=256m # unbounded by default, which is a real risk in
# anything that generates or reloads classes
# Diagnostics you want set BEFORE the incident, not after.
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime:filecount=5,filesize=10M
Reading the current state from inside the JVM
Runtime rt = Runtime.getRuntime();
rt.maxMemory(); // the -Xmx ceiling, in bytes
rt.totalMemory(); // how much has actually been committed so far
rt.freeMemory(); // free within the committed part — NOT the headroom to maxMemory
// used = totalMemory() - freeMemory()
// headroom = maxMemory() - used
// The mistake worth naming: freeMemory() looks like "how much is left" and is
// not. A JVM that has committed 512m of a 4g ceiling reports very little free
// while having 3.5g of room.
# From outside, which is what you will actually have in production.
jcmd <pid> GC.heap_info
jcmd <pid> VM.native_memory summary # needs -XX:NativeMemoryTracking=summary
jcmd <pid> Thread.print # thread dump: every stack, right now
jmap -dump:live,format=b,file=heap.hprof <pid>
Scenarios
A StackOverflowError in a JSON serialiser at 3am. The trace is thousands of frames of the same three methods repeating. That cycle is the answer: two entities reference each other and the serialiser is walking the loop, so this is not a depth problem and raising -Xss converts a fast crash into a slow one. Break the cycle — @JsonIgnore on the back reference, or serialise a DTO instead of the entity. The -Xss conversation is only legitimate when the trace shows different frames all the way down, as in a recursive-descent parser on a deeply nested document.
Your pod restarts on OOMKilled but the heap dump looks fine. The container limit counts everything the process maps, and the heap is only part of that: thread stacks, Metaspace, code cache, direct ByteBuffers and the GC's own structures are all outside -Xmx. A JVM with -Xmx set to the full container limit will be killed by the kernel before it ever throws OutOfMemoryError. Leave headroom — -XX:MaxRAMPercentage=75 is a reasonable default — and turn on native memory tracking before guessing. The clue is the absence of a Java-side error: OOMKilled with no OutOfMemoryError in the log is a native-memory story, not a heap one.
Somebody proposes object pooling to reduce GC pressure. For small short-lived objects on a modern JVM this usually makes things worse, and the measurement above is why: the allocations may not be happening at all, and pooling forces them to, because a pooled object by definition escapes. You also trade a young-generation collection — which costs roughly nothing for objects that die immediately — for a pool that is now shared mutable state with a thread-safety problem. Pooling is still right for genuinely expensive resources: connections, threads, large direct buffers. "Expensive to create" is the test, not "created often".
A batch job gets slower every run despite no code change. Heap usage after each full GC is creeping up across runs of the same input. That pattern — not peak usage, but the floor after collection — is the signature of a leak rather than of a workload that needs more memory. Raising -Xmx here buys you exactly one more run before the same call. Take two heap dumps an hour apart and compare the dominator tree; the growing retained set names the offender, and it is usually a static collection or a cache with no eviction.
Interviewer's Next Move
1. "Are primitives always on the stack?"
No. A primitive local lives in the frame. A primitive field lives inside its object, which is on the heap, and an int[] is a heap object whatever it contains. The stack-versus-heap split is about locals versus the object graph, not about primitives versus objects.
2. "So is every object on the heap?" Not necessarily. Escape analysis lets C2 prove an object never leaves its method, and then scalar replacement dismantles it into fields in registers so the allocation never happens. Measurably: ten million non-escaping objects allocated about 40 KB instead of 160 MB. It is an optimisation, not a guarantee — it disappears if the object is stored, returned or passed somewhere that could retain it.
3. "What is the difference between StackOverflowError and OutOfMemoryError?"
Different regions. The first is one thread's frames, fixed by -Xss if the depth is genuine and by fixing the recursion if it is not. The second is the shared heap — or Metaspace, or native memory — and -Xmx only helps for the heap. Read the message: Java heap space and Requested array size exceeds VM limit need completely different responses.
4. "Where do static fields live?"
Careful — this is the one people get half right. The class's metadata moved to Metaspace in Java 8, so the usual answer is "Metaspace". But JEP 122, the change that removed PermGen, moved static variables and interned strings to the Java heap, where they live inside the java.lang.Class mirror object. So the slot is on the heap, inside -Xmx; the method tables and constant pool next to it are not. Either way a static collection is the classic leak, because the class is a GC root and everything it reaches stays reachable for the life of its class loader.
5. "Virtual threads — where does a virtual thread's stack live?" On the heap, as a chunked continuation object that grows on demand and is copied back when the thread unmounts. That is the whole trick: a platform thread reserves about a megabyte of stack up front whether it uses it or not, and a virtual thread pays only for the frames it actually has. It also means deep recursion in a virtual thread is a heap cost, not a fixed reservation.
Code traps
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 3; i++) {
sb.append(i);
}
// How many objects did this create on the heap?
Answer
Fewer than the obvious count, and the honest answer is "it depends on whether C2 compiled this method". The StringBuilder escapes if it is returned or stored; in a throwaway loop like this, C2 can scalar-replace it. Interpreted, you get the StringBuilder, its internal byte[], and a resize or two. The point of the question is whether you know the answer is JIT-dependent rather than fixed.
void a() { b(); }
void b() { a(); }
// a() is called from a thread started with new Thread(group, r, "t", 64 * 1024).
// What fails, and what is the fix?
Answer
StackOverflowError on that thread only — the other threads are unaffected, because each has its own stack. Raising the stack size is not the fix: the recursion is unbounded, so a bigger stack only postpones it. The trace showing a repeating a/b cycle is how you tell this apart from legitimate depth.
Common wrong answers
| Said in interviews | Reality |
|---|---|
| "Primitives on the stack, objects on the heap." | True only for locals. A primitive field is on the heap, inside its object. |
"Every new allocates on the heap." | Not after escape analysis. Non-escaping objects can be scalar-replaced away entirely. |
"-Xmx sets how much memory the JVM uses." | It caps the heap. Stacks, Metaspace, code cache and direct buffers are all outside it — which is why a container OOMKills a JVM that never threw. |
"freeMemory() tells you how much heap is left." | It reports free space in the committed heap. Headroom is maxMemory() - (totalMemory() - freeMemory()). |
"Increase -Xss to fix StackOverflowError." | Only if the depth is genuine. For unbounded recursion it makes the crash slower, not absent. |
| "PermGen is where class metadata lives." | Not since Java 8. It is Metaspace, in native memory, and it is unbounded by default. |
| "Static fields live in Metaspace." | The metadata does. JEP 122 moved static variables and interned strings to the heap, inside the java.lang.Class object. |
Check Yourself
Q1. Two threads run the same method, which declares int total = 0. Is there one total or two, and why?
Answer
Two. total is a local, so it lives in a frame, and each thread has its own stack and therefore its own frame. Make it a field and there is one copy per object on the shared heap — which is the point at which you need synchronisation.
Q2. Your service is OOMKilled by the container, but there is no OutOfMemoryError in the log and the heap dump never got written. What does that combination tell you?
Answer
The memory that ran out was not the heap. The JVM only throws when the heap is exhausted; the kernel kills the process when its total RSS crosses the container limit, and stacks, Metaspace, code cache, direct buffers and GC structures all count toward that and none count toward -Xmx. Lower -Xmx to leave headroom and turn on native memory tracking.
Q3. A loop creates a million short-lived objects. A colleague wants to pool them. What do you check first?
Answer
Whether the allocations are happening at all. If the objects do not escape the method, C2 can scalar-replace them and the loop allocates almost nothing — pooling would force real allocation and add shared mutable state. Measure with a per-thread allocation counter or a JFR allocation profile after warm-up, then decide.
Practice
| Tier | Exercise | Time |
|---|---|---|
| Warm-up | One object, two references | 5 min |
| Challenge | Measure your own stack | 20 min |
| Production | The report that dies on big tenants | 45 min |
| Interview | Full round replay — heap and stack | 10 min |
What changed, and when
Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.
- Java 6
Escape analysis ships in HotSpot, letting C2 scalar-replace objects that provably never leave a method — so some allocations stop happening at all.
Before Java 6: Every new allocated on the heap, without exception, and short-lived objects were pure GC pressure.
- Java 8LTS
PermGen is removed (JEP 122). Class metadata moves to Metaspace in native memory, while static variables and interned strings move to the ordinary heap — so -XX:MaxPermSize no longer exists and the two halves are now in different places.
Before Java 8 (now gone): Class metadata, static variables and interned strings all sat together in a fixed-size PermGen inside the heap, and java.lang.OutOfMemoryError: PermGen space was a routine production failure.
- Java 21LTS
Virtual threads make stacks a heap concern: a virtual thread's stack lives on the heap as a chunked continuation and grows on demand, so a million of them is affordable.
Before Java 21: Every thread meant one OS thread with a fixed reserved stack — typically 1 MB — which is why thread counts were capped in the low thousands.
Practice ladder
Reading this page is not knowing it. Four tiers, ending in a real incident.
- Warm-up5 min
One object, two references
One concept, guided. Near-impossible to fail.
- Challenge20 min
Measure your own stack
Edge cases. You have to reason, and two valid fixes differ.
- Production incident45 min
The report that dies on one tenant
A real incident: symptom first, cause hidden, tradeoff at the end.
- Interview replay10 min
Full round replay — heap and stack
Timed verbal replay with pass/fail criteria per follow-up.
Where this question goes next
- memory leaks in java — not written yet
- metaspace vs permgen — not written yet
- Why does my recursion throw StackOverflowError, and when should I use a loop instead?
Every runnable example above was compiled and executed against openjdk 21.0.12 on this build, and its output diffed against what this page claims. Last updated 2026-09-13.