Skip to content

Memory — Heap & Stack Interview Questions & Answers

21 questions Updated 2026-06-20 Share:

Java JVM memory interview questions — heap vs stack, stack frames, object allocation, Eden/Survivor/Old Gen regions, Metaspace, escape analysis, OutOfMemoryError, and StackOverflowError.

Read the in-depth guideJava JVM Memory Explained — Heap, Stack, and Everything In Between(opens in new tab)
21 of 21

The stack is a per-thread memory region that stores stack frames — local variables, method parameters, and return addresses for each active method call. It is LIFO and automatically reclaimed when a frame pops. The heap is shared across all threads and holds every object instance and every array created with new.

void demo() {
    int x = 42;                // x lives on the stack (primitive local)
    String s = new String("hi"); // the String object lives on the heap;
                                 // s (the reference) lives on the stack
}

Rule of thumb: if it was created with new, it's on the heap; local primitives and references themselves live on the stack.

Each method invocation creates a stack frame containing:

  • Local variable array — method parameters and local variables (primitives stored by value, object references stored as pointers).
  • Operand stack — a work area the JVM uses to execute bytecode instructions (push, pop, arithmetic).
  • Frame data — a reference to the runtime constant pool entry for the current class and the return address.
int add(int a, int b) {   // a and b are in the local variable array
    int sum = a + b;      // sum is also in the local variable array
    return sum;           // frame pops; sum is gone
}

Rule of thumb: the stack frame is the method's private scratch pad — it is created on call and destroyed on return.

The reference variable (the pointer) lives on the stack (or as a field inside another heap object), while the object it points to always lives on the heap.

void foo() {
    StringBuilder sb = new StringBuilder(); // ref 'sb' is on the stack;
                                            // the StringBuilder is on the heap
    sb.append("hi");
}   // frame pops → 'sb' gone; object may be GC'd if no other references

Rule of thumb: never confuse the address with the house — the reference is the address card, the object is the house.

The HotSpot heap uses a generational layout for the default GC collectors:

Region Purpose
Eden new objects allocated here first
Survivor 0 / S1 objects that survived one GC cycle
Old Gen (Tenured) long-lived objects promoted from Young Gen

Eden + Survivor spaces together form the Young Generation. A Minor GC collects Young Gen; a Major/Full GC also collects Old Gen.

// From the JVM perspective:
// new Object() → allocated in Eden
// survives N minor GCs → copied to Survivor space
// reaches tenure threshold → promoted to Old Gen

Rule of thumb: short-lived objects die young in Eden; objects that survive long enough are promoted to Old Gen where collection is expensive.

PermGen (Permanent Generation) was a fixed-size heap region in Java ≤ 7 that stored class metadata, interned strings, and static fields. It was notorious for OutOfMemoryError: PermGen space when too many classes were loaded (e.g., by hot-deploy in app servers).

Java 8 replaced it with Metaspace, which lives in native memory (outside the Java heap) and grows dynamically by default.

# Java 7 and earlier:
-XX:MaxPermSize=256m    # had to be tuned manually

# Java 8+:
-XX:MaxMetaspaceSize=256m  # optional cap; unlimited by default

Rule of thumb: Metaspace can't cause PermGen errors because it is not on the heap — but without a cap it can exhaust native memory, so set -XX:MaxMetaspaceSize in long-running servers.

Each thread has a fixed-size stack. When method calls nest too deeply — almost always due to unbounded recursion — the stack runs out of space and the JVM throws StackOverflowError.

int factorial(int n) {
    return n * factorial(n - 1); // no base case → infinite recursion
}
// Throws: java.lang.StackOverflowError

The default thread stack size is typically 512 KB – 1 MB (JVM-dependent). It can be changed with -Xss (e.g., -Xss2m).

Rule of thumb: StackOverflowError almost always means missing or incorrect base case in recursion; check the call chain, not the heap.

The JVM throws OutOfMemoryError: Java heap space when it cannot allocate a new object even after running GC and expanding the heap to -Xmx. Common causes are memory leaks (objects retained unintentionally), large data loads (reading an entire file into memory), or a heap that is simply too small for the workload.

List<byte[]> leak = new ArrayList<>();
while (true) {
    leak.add(new byte[1024 * 1024]); // keeps reference → GC can't collect
}
// java.lang.OutOfMemoryError: Java heap space

Diagnose with a heap dump (-XX:+HeapDumpOnOutOfMemoryError) and inspect with MAT or VisualVM.

Rule of thumb: OOM: Java heap space means either the heap is too small or something is holding references it shouldn't.

-Xms sets the initial heap size and -Xmx sets the maximum heap size. The JVM starts with Xms and grows up to Xmx as needed.

java -Xms512m -Xmx2g MyApp
# starts with 512 MB heap, can grow to 2 GB

Setting them equal avoids heap-resize pauses in production (no expansion/shrink cycles), at the cost of reserving the full memory upfront.

Rule of thumb: in production containers set -Xms = -Xmx to keep heap size predictable and prevent GC-driven resize pauses.

Escape analysis is a JIT optimization where the compiler determines whether an object's reference escapes the method or thread it was created in. If it doesn't escape, the JVM can allocate it on the stack or scalar-replace it (break it into individual fields), avoiding heap allocation and GC pressure entirely.

void compute() {
    Point p = new Point(1, 2); // if p never escapes this method…
    int sum = p.x + p.y;       // …JIT may replace it with two int locals
}                              // no heap allocation, no GC overhead

Escape analysis is enabled by default in HotSpot (-XX:+DoEscapeAnalysis). It explains why micro-benchmarks allocating temporary objects sometimes show surprisingly low GC activity.

Rule of thumb: you can't force escape analysis, but writing short-lived helper objects inside a method (not passing them out) gives the JIT the best chance to eliminate the allocation.

Each thread has its own stack. Local variables (including primitive locals and object references local to a method) exist only in that thread's stack frames. No other thread can see or modify them, so there is no sharing and no synchronization is needed.

void process() {
    int counter = 0;        // on this thread's stack — invisible to others
    counter++;              // safe, no volatile/synchronized needed
}

Objects on the heap, however, can be reachable from multiple threads via shared references, which is where thread-safety concerns arise.

Rule of thumb: stack data is private to its thread; only shared heap data needs synchronization.

Every heap object has a hidden object header prepended by the JVM, typically 16 bytes on a 64-bit JVM (or 12 bytes with compressed oops):

Part Size Purpose
Mark word 8 bytes hashCode, GC age bits, lock state, biased-locking info
Class pointer 4–8 bytes pointer to the class metadata in Metaspace

After the header come the instance fields, then padding to align to 8 bytes.

// Rough layout of: new Object()
// [mark word 8 bytes][class ptr 4 bytes][4 bytes padding] = 16 bytes minimum

Rule of thumb: even an empty new Object() costs at least 16 bytes on heap — object count matters for memory budgets, not just field sizes.

A Minor GC (also called a Young GC) collects only the Young Generation (Eden + Survivor spaces). It is fast because Young Gen is small and most objects there are already dead. It causes a short stop-the-world pause.

A Full GC (or Major GC) collects the entire heap — Young Gen and Old Gen — and also processes Metaspace. It is much slower and causes a longer pause. It is triggered when Old Gen fills up or when System.gc() is called.

// Triggering a Full GC deliberately (rarely advisable in production):
System.gc();  // hint only; JVM may ignore it

Rule of thumb: frequent Full GCs are a red flag — they signal that objects are promoted to Old Gen faster than it can be cleaned, often due to a memory leak or undersized heap.

The String pool (interned String table) lives on the heap in Java 7+ (it was in PermGen in Java 6 and earlier). String literals are automatically interned; calling String.intern() manually adds a string to the pool.

String a = "hello";           // from the string pool
String b = "hello";           // same pooled object
String c = new String("hello"); // new heap object, NOT pooled
System.out.println(a == b);   // true  — same pool reference
System.out.println(a == c);   // false — c is a distinct heap object

Because the pool is on the heap it is subject to GC, so interned strings that are no longer referenced can be collected (unlike PermGen, which was never GC'd in Java 6).

Rule of thumb: prefer equals() for string comparison; == only works reliably on pool references (literals or explicitly interned strings).

On a 64-bit JVM, a raw pointer to a heap object is 8 bytes. Compressed oops (-XX:+UseCompressedOops, enabled by default when heap ≤ 32 GB) store object references as 32-bit values by encoding the pointer as a word offset (address >> 3). The JVM shifts the value back to a full 64-bit address at use time, transparently.

# Enabled automatically when -Xmx <= ~32 GB:
-XX:+UseCompressedOops      # compress heap references
-XX:+UseCompressedClassPointers  # also compress class pointers in header

This reduces per-object memory by shrinking every reference field and the class pointer in the header from 8 → 4 bytes, which often cuts heap usage by 20–30 %.

Rule of thumb: keep heap below 32 GB to retain compressed oops; crossing that boundary can actually increase memory usage because every reference grows from 4 to 8 bytes.

Allocating on the heap naively would require synchronization on every new because all threads share Eden. The JVM avoids this by giving each thread its own private chunk of Eden called a TLAB. Allocations within the thread just bump a pointer inside the TLAB — no locking needed.

// Conceptually:
Thread T1 → has TLAB [0x1000 – 0x2000]
Thread T2 → has TLAB [0x2000 – 0x3000]
new Foo() in T1 → bumps T1's pointer; no synchronization

When a TLAB is exhausted the thread requests a fresh one from Eden (under a brief lock). Objects too large for any TLAB are allocated directly in Eden or Old Gen.

Rule of thumb: TLABs are why Java object allocation is nearly as cheap as incrementing a pointer — the cost shows up at GC time, not at new.

Java provides reference types that let the GC reclaim objects under different urgency levels, avoiding OutOfMemoryError for caches:

Type Cleared when Use case
SoftReference<T> GC needs memory (before OOM) memory-sensitive caches
WeakReference<T> next GC, regardless of memory canonicalizing maps (WeakHashMap)
PhantomReference<T> after finalization, just before reclaim post-mortem cleanup / resource release
Cache<K, V> cache = new LinkedHashMap<>();
SoftReference<BigData> ref = new SoftReference<>(loadData());
BigData d = ref.get(); // null if GC has already cleared it

Rule of thumb: use SoftReference for caches you want the GC to evict under pressure, WeakReference when you don't want to prevent collection, and PhantomReference only when you need a GC notification callback.

GC roots are the starting points from which the garbage collector traces live object graphs. Any object reachable from a GC root is considered live and will not be collected. Common GC roots include:

  • Local variables and operand-stack entries in active stack frames
  • Static fields of loaded classes
  • References held by JNI (native code)
  • Objects referenced by active threads themselves
static List<Object> cache = new ArrayList<>(); // static field = GC root
// anything added to 'cache' will never be GC'd while the class is loaded

Memory leaks in Java are almost always objects that remain reachable from a GC root unintentionally (e.g., a static list that grows forever).

Rule of thumb: a Java memory leak is not "memory the GC can't see" but "memory the GC won't collect because a root still points to it."

The default thread stack size (typically 512 KB on 64-bit Linux) is sufficient for most apps. You'd increase it with -Xss if deep but legitimate recursion (e.g., recursive descent parsers, deep call chains in frameworks) causes StackOverflowError.

java -Xss2m MyApp   # 2 MB stack per thread

Be careful: each thread gets its own stack, so doubling -Xss in an app with 500 threads doubles the native memory reserved for stacks. This memory comes from native (off-heap) memory, not from the Java heap.

Rule of thumb: increase -Xss only when you own the recursion and can't refactor it to iteration; otherwise fix the algorithm.

Native memory (also called off-heap memory) is memory the JVM allocates from the OS directly, outside the Java heap. It is not managed by the garbage collector. Key consumers include:

Consumer Notes
Thread stacks one per thread × -Xss
Metaspace class metadata
Code cache JIT-compiled native code
Direct ByteBuffers ByteBuffer.allocateDirect()
GC data structures card tables, remembered sets
ByteBuffer buf = ByteBuffer.allocateDirect(64 * 1024 * 1024); // 64 MB off-heap
// not counted in -Xmx, freed when buf is GC'd or explicitly with Cleaner

Rule of thumb: if a process's RSS grows well beyond -Xmx, the culprit is usually native memory — Metaspace, thread stacks, or direct buffers.

Capture a heap dump while the leak is active, then inspect it with a tool like Eclipse MAT or VisualVM to find the dominator tree — the objects retaining the most memory.

# Enable automatic dump on OOM:
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/dump.hprof MyApp

# Manual dump of running process (PID 12345):
jmap -dump:live,format=b,file=/tmp/dump.hprof 12345

In MAT, the "Leak Suspects" report identifies objects accumulating in memory and traces the shortest path back to a GC root — that path reveals why the object isn't being collected.

Rule of thumb: always capture the heap dump while the problem is occurring; a dump taken after a restart won't show the leaked objects.

The weak generational hypothesis is the empirical observation that most objects die young — they are allocated, used briefly, and become garbage within a few milliseconds. A minority of objects survive long term.

This drives the generational GC design: allocate cheaply in a small, frequently-collected Young Gen where most objects die without ever touching Old Gen. Only survivors get promoted to the expensive-to-collect Old Gen.

Typical production allocation profile:
  ~90 % of objects die before their first Minor GC
  ~5–9 % get promoted after a few GC cycles
  ~1 % live for the application's lifetime (caches, singletons)

Rule of thumb: generational GC works well when you respect the hypothesis — minimize long-lived object graphs and avoid putting ephemeral objects in static caches.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel