The stack is a notepad for a method — variables appear when the method starts and vanish the moment it returns. The heap is a shared workspace where objects live until nobody needs them. Understanding which memory region holds what determines how you reason about object lifetime, threading safety, and GC pressure.
Stack vs heap is one of those fundamentals that separates developers who write code from developers who understand what their code does at runtime. I've diagnosed StackOverflowErrors from unbounded recursion in production and tuned JVM heap flags for services handling 50,000 requests per minute. Both incidents would have been faster to resolve if the engineers involved had a clear mental model of memory allocation.
How Stack Memory Works
Every thread has its own call stack. When a method is called, the JVM pushes a new stack frame containing: the method's local variables and parameters, the method's return address, and intermediate computation results. When the method returns, the frame is popped and the memory is instantly reclaimed — no garbage collection, no overhead.
Stack allocation is O(1) — incrementing a pointer. That's why local primitives are fast. The downside: the stack is small (256KB–1MB per thread by default). Deep or unbounded recursion fills it and throws StackOverflowError.
StackMemoryDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package io.thecodeforge.memory;
publicclassStackMemoryDemo {
publicstaticvoidmain(String[] args) {
// 'multiplier' lives on main()'s stack frameint multiplier = 3;
// A new stack frame is created for calculate(5)int result = calculate(5);
System.out.println(result * multiplier); // 45
} // main() stack frame popped — 'multiplier' and 'result' gonestaticintcalculate(int input) {
int doubled = input * 2; // on calculate()'s stack frame
int added = doubled + 25; // also on this framereturn added;
} // Frame popped — 'doubled' and 'added' are gone immediately// StackOverflowError: no base case, frames accumulate indefinitelystaticvoidunbounded(int n) {
unbounded(n + 1);
}
// Safe: bounded recursion with a base casestaticintfactorial(int n) {
if (n <= 1) return 1; // Base case — stops recursion
return n * factorial(n - 1); // At most n frames on stack
}
}
Output
45
// unbounded() produces:
// Exception in thread 'main' java.lang.StackOverflowError
// at io.thecodeforge.memory.StackMemoryDemo.unbounded(StackMemoryDemo.java:16)
// at io.thecodeforge.memory.StackMemoryDemo.unbounded(StackMemoryDemo.java:16)
// ... (thousands more identical frames)
Production Insight
We once saw a JSON deserialisation library that used recursion to parse nested objects. A deeply nested payload (100+ levels) killed every thread.
Fix: replaced recursion with iterative stack-and-loop parsing.
Rule: never trust input depth — always bound recursion or use iteration.
StackOverflowError means too many frames — usually unbounded recursion.
Memo: stack is a self-destructing notepad for each method call.
How Heap Memory Works
The heap is where objects live. new PaymentService() allocates the object on the heap. The reference (a 4-8 byte pointer) to that object lives on the stack (or inside another object on the heap). When no references point to an object, it becomes eligible for garbage collection.
The JVM heap is divided into generations. Young generation holds newly created objects — minor GC runs frequently here and is fast. Objects that survive several minor GCs are promoted to Old generation — collected less often, in a major GC that can pause the application. Creating millions of short-lived objects in a hot path causes 'GC pressure' — frequent minor GCs that add latency spikes even when total memory use seems fine.
Configure heap size with JVM flags: -Xms for initial size, -Xmx for maximum. Each thread's stack size is configured with -Xss.
HeapMemoryDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package io.thecodeforge.memory;
publicclassHeapMemoryDemo {
// Object fields live on the heap (part of the object)staticclassPaymentRecord {
String paymentId; // reference on heap; String object also on heap
int amountPence; // primitive stored directly in the object on heapboolean processed;
}
publicstaticvoidmain(String[] args) {
// 'record' reference: on main()'s stack frame (4-8 bytes)// PaymentRecord object: on the heap (size = fields + object header ~16 bytes)PaymentRecord record = newPaymentRecord();
record.paymentId = "pay-42";
record.amountPence = 10_000; // £100.00
record.processed = false;
process(record); // passes the reference — NOT a copy of the object
record = null; // No more references → object eligible for GC
} // 'record' reference popped from stack; object waits for GCstaticvoidprocess(PaymentRecord r) {
// 'r' is a copy of the reference on THIS frame's stack// r.processed = true modifies the SAME object on the heap
r.processed = true;
System.out.println("Processed: " + r.paymentId);
} // 'r' reference gone; heap object still exists (main() still holds it)// JVM flags for memory tuning:// java -Xms512m -Xmx2g -Xss512k -jar app.jar// ^^^^^^^^ ^^^^ ^^^^^^^ // init heap max stack per thread
}
Output
Processed: pay-42
// Memory layout while process() runs:
// Stack (main frame): record → 0x1a2b3c4d
// Stack (process frame): r → 0x1a2b3c4d (same address)
In a trading system, we saw minor GCs every 200ms because a hot loop created a new BigDecimal per trade. Each allocation went to Young Gen.
Fix: reused a single BigDecimal instance with setScale.
Rule: watch out for allocation in hot paths — they spike GC frequency even if total memory is low.
Key Takeaway
Heap = shared, GC-managed, objects live until unreferenced.
GC overhead comes from allocation rate, not just heap size.
Memo: heap is a warehouse — objects stay until someone stops pointing at them.
Why StackOverflowError Happens and How to Prevent It
StackOverflowError occurs when a thread's call stack exhausts its allocated memory. The most common cause is unbounded recursion — a method that calls itself without reaching a base case. But it can also happen with deep call trees (thousands of method calls) even without recursion. For example, a recursive directory walker on a deep file system, or a parser processing deeply nested JSON.
The default stack size is platform-dependent but typically 1MB for 64-bit Linux. You can increase it with -Xss2m but that only delays the inevitable. The real fix is to either limit recursion depth with a guard or rewrite the algorithm iteratively using an explicit stack (like Deque).
PreventStackOverflow.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package io.thecodeforge.memory;
publicclassPreventStackOverflow {
// RISKY: recursive JSON-like parser without depth limitstaticvoidparseNested(JsonNode node) {
if (node.isObject()) {
for (JsonNode child : node) {
parseNested(child); // no depth check!
}
}
}
// SAFE: iterative version using explicit stackstaticvoidparseIteratively(JsonNode root) {
Deque<JsonNode> stack = newArrayDeque<>();
stack.push(root);
while (!stack.isEmpty()) {
JsonNode node = stack.pop();
if (node.isObject()) {
for (JsonNode child : node) {
stack.push(child);
}
}
}
}
// SAFE: recursive with depth guardstaticvoidparseSafely(JsonNode node, int depth) {
if (depth > 100) thrownewRuntimeException("Nesting too deep");
if (node.isObject()) {
for (JsonNode child : node) {
parseSafely(child, depth + 1);
}
}
}
publicstaticvoidmain(String[] args) {
// Simulate deep JSON - see how parseNested fails// Use parseIteratively or parseSafely instead
}
}
Output
// Exception in thread "main" java.lang.StackOverflowError
// at PreventStackOverflow.parseNested(PreventStackOverflow.java:5)
// ...
// No output for safe versions — they complete normally.
Production Insight
A payment reconciliation job recursively walked a tree of transactions. A customer's account had a cycle (parent-child loop in data) causing infinite recursion. The job crashed every run.
Fix: added a visited set in the iterative version to detect cycles.
Rule: always assume external input can cause deep or cyclic structures.
Key Takeaway
StackOverflowError = stack memory exhausted.
Root cause: unbounded recursion or too many nested calls.
Fix: depth guard, iterative rewrite, or cycle detection.
Memo: stack is finite — check your recursion depth at scale.
OutOfMemoryError: Heap Full — Leaks, Pressure, and Tuning
OutOfMemoryError: Java heap space happens when the garbage collector cannot allocate a new object because the heap is full. This can be a memory leak (objects held unintentionally) or simply too many live objects for the configured heap size. The GC tries to reclaim space but if no objects are eligible (all are reachable), it gives up.
Fixing an OOM requires tools: heap dumps (jmap -dump:live), heap histograms (jmap -histo), and profilers (VisualVM, Eclipse MAT). Look for unexpected large object counts — often a cache without eviction, a thread pool holding references, or a listener that was never unregistered.
JVM tuning flags help:-XX:+HeapDumpOnOutOfMemoryError saves a dump automatically. -XX:MaxGCPauseMillis sets a target for GC pause times. Choosing the right collector matters: G1 is default for large heaps, ZGC/Shenandoah for sub-millisecond pauses.
OOMDebug.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package io.thecodeforge.memory;
import java.util.*;
publicclassOOMDebug {
staticclassEvent {
long timestamp;
byte[] payload = new byte[1024]; // 1KB each
}
staticMap<String, Event> eventCache = newHashMap<>();
publicstaticvoidmain(String[] args) {
// Simulate a memory leak: events are added but never removedfor (int i = 0; i < 1_000_000; i++) {
eventCache.put("event-" + i, newEvent());
}
// This will throw OutOfMemoryError unless heap is huge
}
}
Output
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at OOMDebug$Event.<init>(OOMDebug.java:6)
at OOMDebug.main(OOMDebug.java:14)
Production Insight
We had a microservice that loaded a configuration from a JSON file on startup. The file contained a list of thousands of IP addresses stored in a static List. That list was never cleared, and each reload created a new list, leaving the old one to accumulate. After 100 redeploys, the old lists filled the heap.
Fix: used a single mutable static reference and replaced it on reload.
Rule: static collections are global roots — GC can't collect them until the class is unloaded.
Key Takeaway
OutOfMemoryError = heap has no room for new objects.
Most common root cause: unintentionally held references (leak) or insufficient -Xmx.
Debug with heap dumps — find the dominating objects.
Memo: heap grows but never shrinks; plan for max concurrent objects.
Stack vs Heap: The Multithreading Angle
Each thread has its own stack, so local variables are inherently thread-safe. No two threads can interfere with each other's stack frames. That's a big deal — it means you can use local primitives and references without synchronisation.
Heap objects, on the other hand, are shared. If two threads hold references to the same object (or one thread gets a reference via a field of another object), they can race on that object's state. This is why instance fields need locks or atomic operations.
A common misconception: passing a copy of an object reference to another method is safe because the reference is on the stack. But both frames point to the same heap object. If one thread modifies that object's fields and another reads them without proper synchronisation, you get a data race.
Also note: the stack itself is not visible to other threads — they can't see your local variables. But if you store a local reference into a static field or a shared collection, the object referenced becomes shared.
ThreadSafety.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package io.thecodeforge.memory;
publicclassThreadSafety {
staticclassSharedCounter {
int count; // heap, shared, needs synchronizationsynchronizedvoidincrement() {
count++; // safe with synchronized
}
}
publicstaticvoidmain(String[] args) throwsException {
SharedCounter counter = newSharedCounter();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
};
Thread t1 = newThread(task);
Thread t2 = newThread(task);
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(counter.count); // always 2000 with synchronized
}
// Compare: local variable is thread-safestaticvoidlocalSum() {
int local = 0; // on this thread's stackfor (int i = 0; i < 1000; i++) {
local++;
}
// No other thread can see 'local'
}
}
Output
2000
// Without synchronized on increment(), output would vary (e.g., 1847)
Production Insight
A monitoring system logged metrics by passing a mutable MetricsSnapshot object to a thread pool. The snapshot had a List<Long> field. Multiple threads wrote to that list without synchronisation, causing index corruption and lost data.
Fix: each thread created its own local snapshot and merged results after completion.
Rule: if you pass a mutable object to multiple threads, it's shared heap data — synchronise or copy.
Key Takeaway
Stack = per-thread, no sync needed for locals.
Heap = shared, synchronise if mutable and accessed by >1 thread.
You can't synchronise on stack variables — only on heap objects.
Memo: thread stacks are private silos; heap is a public square.
● Production incidentPOST-MORTEMseverity: high
OutOfMemoryError in a Payment Processing Service
Symptom
OutOfMemoryError: Java heap space after ~2 hours of steady traffic. Full GCs were running every 30 seconds and not reclaiming memory.
Assumption
The team assumed the heap was too small. They doubled -Xmx from 2GB to 4GB. The error still occurred, now after 4 hours — linear scaling, not a fix.
Root cause
A thread pool executor was caching CompletableFuture objects for retry logic. Each retry created a new PaymentRecord object and held a reference in a ConcurrentHashMap that was never cleared. Unprocessed payments accumulated, preventing GC from reclaiming any of them.
Fix
Added a scheduled task to remove entries older than 5 minutes from the retry map. Then switched to a bounded queue with a rejection policy for the executor. Also added -XX:+HeapDumpOnOutOfMemoryError and -XX:HeapDumpPath for future diagnosis.
Key lesson
Always investigate the source of heap growth before increasing -Xmx.
Use heap dumps and profilers (jmap, Eclipse MAT) to find unreleased references.
Bounded caches with TTLs prevent unbounded object accumulation.
Monitor GC frequency and pause times — they tell you if the heap is healthy or sick.
Production debug guideSymptom → Action for the most common memory failures4 entries
Symptom · 01
Application throws StackOverflowError
→
Fix
Check the stack trace in the logs. The repeated frames tell you which method recursed. Add a base-case guard or convert to iteration.
Symptom · 02
OutOfMemoryError: Java heap space
→
Fix
First: capture a heap dump with jmap -dump:live,format=b,file=heap.hprof <pid>. Then load into MAT or VisualVM to find the leak suspects.
Symptom · 03
GC overhead limit exceeded (98% of time spent in GC with <2% heap recovered)
→
Fix
The heap is too small or there's a memory leak. Check -Xmx settings. If the heap dump shows many objects of the same type, review their lifecycle.
Symptom · 04
Application becomes slow intermittently, with GC pause spikes
→
Fix
Enable GC logs: -Xlog:gc*. Use GCeasy or gceasy.io to analyse pause times. Consider switching to a low-pause collector (ZGC, Shenandoah) if G1 pauses are too long.
★ Quick Stack/Heap Debug CommandsCommands to diagnose memory issues in production — no theory, just action.
StackOverflowError in logs−
Immediate action
Find the repeating stack frame in the error trace. That method is recursing without termination.
Commands
grep -A 50 'StackOverflowError' application.log | head -60
jstack <pid> # get thread dump to see all stacks
Fix now
Add a base case or convert recursive method to iterative loop.
OutOfMemoryError or high GC activity+
Immediate action
Check heap usage: jstat -gcutil <pid> 1s 10. If Old Gen is at 95%+ and not dropping, there's a leak.
jmap -dump:live,format=b,file=heap.hprof <pid> # then load in MAT
Fix now
If a single object type dominates, find where it's allocated and why it's retained. Add explicit nulling or weak references.
GC pauses >500ms causing latency spikes+
Immediate action
Enable GC logging and check pause times.
Commands
java -Xlog:gc* -jar app.jar (add to startup flags)
jstat -gc <pid> 1s 10 # live GC stats
Fix now
Reduce young generation size to shorten minor GC pauses, or switch to ZGC/Shenandoah for sub-millisecond pauses.
Characteristic
Stack
Heap
Stores
Local vars, params, references
Objects, instance fields, arrays
Managed by
JVM (LIFO, automatic)
Garbage Collector
Size
256KB–1MB per thread (default)
Configurable with -Xmx (GBs)
Speed
Very fast (pointer increment)
Slower (allocation + GC overhead)
Lifetime
Method execution scope
Until no references remain
Error when full
StackOverflowError
OutOfMemoryError
Thread access
Private per thread
Shared by all threads
GC involved?
No
Yes
Key takeaways
1
Stack holds method frames (local variables, parameters, return addresses) and is automatically reclaimed when a method returns. Heap holds objects and is managed by the GC.
2
Stack memory is thread-private
each thread has its own stack. Heap memory is shared across all threads, which is why heap objects need synchronisation.
3
StackOverflowError = stack full from unbounded recursion. OutOfMemoryError = heap full from too many live objects.
4
Passing an object to a method passes the reference (cheap pointer copy). The method sees the same heap object and can mutate its fields.
5
JVM flags
-Xms (initial heap), -Xmx (max heap), -Xss (stack size per thread). Tune these based on your application's object allocation profile.
Common mistakes to avoid
4 patterns
×
Assuming primitives always live on the stack
Symptom
Primitive fields of an object are allocated on the heap as part of the object. Only local primitive variables in method bodies live on the stack. This confuses engineers debugging memory usage of large objects.
Fix
Remember: fields of an object are always on the heap, regardless of their type. If you create a class with 100 int fields, that's 400 bytes on the heap per object.
×
Confusing passing a reference with copying an object
Symptom
When you pass an object to a method, you pass the reference (4-8 bytes). The method can modify the object's fields but cannot change what the caller's variable points to. Engineers often think they're passing a clone.
Fix
If you need to avoid mutation, either make the object immutable or pass a defensive copy using a copy constructor or clone() method.
×
Not understanding that stack is thread-private
Symptom
Race conditions are incorrectly assumed possible on local variables. But local variables are on the stack, which is per-thread. Race conditions only occur on heap objects accessed by multiple threads.
Fix
If you see a race condition, check if the shared mutable state is on the heap (instance/static fields or objects passed between threads).
×
Creating large temporary objects in hot loops
Symptom
Each allocation goes to Young generation. Frequent minor GC cycles from unnecessary allocations cause latency spikes even when memory seems plentiful. This is a common performance killer in high-throughput systems.
Fix
Profile allocation hotspots with a profiler. Use object pooling or reuse objects in hot paths. Be aware that escape analysis may allocate some objects on the stack (if they never escape the method), but that's not guaranteed.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01JUNIOR
What is the difference between stack and heap memory? Where does a Java ...
Q02SENIOR
A method creates a List with 10,000 elements and passes it to an...
Q03SENIOR
Why is stack memory thread-safe while heap memory is not?
Q04SENIOR
What causes StackOverflowError vs OutOfMemoryError? How would you debug ...
Q01 of 04JUNIOR
What is the difference between stack and heap memory? Where does a Java object live?
ANSWER
The stack stores method frames — local variables, parameters, and return addresses. It's per-thread, LIFO, and automatically reclaimed when a method returns. The heap stores all Java objects (created with 'new') and arrays. Objects live on the heap; references to them live on the stack or inside other heap objects. The heap is managed by GC.
Q02 of 04SENIOR
A method creates a List with 10,000 elements and passes it to another method. What happens in memory?
ANSWER
The list object and its internal array (holding references to 10,000 String objects) are allocated on the heap. The reference to the list is stored as a local variable on the caller's stack frame. When passed to the second method, a copy of that reference is placed on the second method's stack frame. Both references point to the same heap object. The String objects themselves are also on the heap (unless they're interned). If the second method modifies the list (e.g., adds/removes elements), the caller will see those changes because they share the same heap object.
Q03 of 04SENIOR
Why is stack memory thread-safe while heap memory is not?
ANSWER
Each thread has its own call stack. Local variables and references on a thread's stack are inaccessible to other threads — there's no way for a second thread to reference them directly. Therefore, accesses to stack data are inherently thread-safe. Heap memory is shared — objects on the heap can be referenced by multiple threads simultaneously. If two threads modify the same object without synchronisation, you get race conditions. Synchronisation primitives like 'synchronized', volatile, and atomic classes are needed to ensure visibility and atomicity for heap data.
Q04 of 04SENIOR
What causes StackOverflowError vs OutOfMemoryError? How would you debug each?
ANSWER
StackOverflowError occurs when the call stack runs out of space — typically from unbounded recursion. The stack trace shows the repeating method call. Debug: look at the stack trace, find the method with deep recursion, add a base case or convert to iteration. OutOfMemoryError happens when the heap is full and GC cannot free enough space. It can be a memory leak or insufficient heap size. Debug: enable heap dumps with -XX:+HeapDumpOnOutOfMemoryError, analyse with MAT/VisualVM to find the leaking objects. Also use jstat to monitor GC activity and jmap -histo to see object counts.
01
What is the difference between stack and heap memory? Where does a Java object live?
JUNIOR
02
A method creates a List with 10,000 elements and passes it to another method. What happens in memory?
SENIOR
03
Why is stack memory thread-safe while heap memory is not?
SENIOR
04
What causes StackOverflowError vs OutOfMemoryError? How would you debug each?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
What is the difference between stack and heap memory in Java?
The stack stores method frames containing local variables, parameters, and return addresses. It's thread-private and automatically reclaimed when a method returns. The heap stores objects created with 'new' and is managed by the garbage collector. Objects live on the heap until no references point to them.
Was this helpful?
02
Where do primitive variables live in Java?
It depends on context. Primitive local variables and method parameters live on the stack. Primitive fields of an object live on the heap as part of that object's memory block.
Was this helpful?
03
What causes StackOverflowError?
Unbounded recursion — a method that calls itself without a base case that terminates. Each call adds a frame to the stack. When the stack exhausts its allocated space (typically 256KB–1MB per thread), the JVM throws StackOverflowError.
Was this helpful?
04
How can I find a memory leak in production?
Start with jmap -histo:live <pid> to see the most numerous objects. If you see unexpected types dominating, generate a heap dump with jmap -dump:live,format=b,file=heap.hprof <pid> and analyse it with Eclipse MAT or VisualVM. Look for objects that should have been garbage collected but are held by strong references (static collections, listeners, caches without eviction).
Was this helpful?
05
Can objects be allocated on the stack?
In some cases, yes. The JVM's escape analysis determines if an object's reference never leaves the method. If it doesn't escape, the JVM can allocate it on the stack (or even inline its fields into registers). This is called stack allocation and avoids GC overhead. However, it's not guaranteed and depends on the JIT compiler.