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
localvariables (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 JVM Internals interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.