Senior 3 min · March 30, 2026

Stack vs Heap Memory — GC Failed to Reclaim Payment Cache

OutOfMemoryError after 2 hours: full GC every 30s failing to reclaim heap.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Stack: thread-private, LIFO, stores method frames with local vars and references, auto-reclaimed
  • Heap: shared, stores objects, managed by garbage collector, survives method calls
  • StackOverflowError: unbounded recursion fills small stack (256KB-1MB per thread)
  • OutOfMemoryError: heap full — too many live objects or GC not keeping up
  • Key insight: references live on stack, objects live on heap; pass by value copies the reference
  • Performance: stack allocation is O(1) pointer increment; heap allocation triggers GC overhead
Plain-English First

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;

public class StackMemoryDemo {

    public static void main(String[] args) {
        // 'multiplier' lives on main()'s stack frame
        int 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' gone

    static int calculate(int input) {
        int doubled = input * 2;    // on calculate()'s stack frame
        int added   = doubled + 25; // also on this frame
        return added;
    } // Frame popped — 'doubled' and 'added' are gone immediately

    // StackOverflowError: no base case, frames accumulate indefinitely
    static void unbounded(int n) {
        unbounded(n + 1);
    }

    // Safe: bounded recursion with a base case
    static int factorial(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.
Key Takeaway
Stack = per-thread, self-cleaning, O(1) allocation.
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;

public class HeapMemoryDemo {

    // Object fields live on the heap (part of the object)
    static class PaymentRecord {
        String    paymentId;    // reference on heap; String object also on heap
        int       amountPence;  // primitive stored directly in the object on heap
        boolean   processed;
    }

    public static void main(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 = new PaymentRecord();
        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 GC

    static void process(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)
// Heap (0x1a2b3c4d): PaymentRecord{paymentId='pay-42', amountPence=10000, processed=true}
Production Insight
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;

public class PreventStackOverflow {

    // RISKY: recursive JSON-like parser without depth limit
    static void parseNested(JsonNode node) {
        if (node.isObject()) {
            for (JsonNode child : node) {
                parseNested(child); // no depth check!
            }
        }
    }

    // SAFE: iterative version using explicit stack
    static void parseIteratively(JsonNode root) {
        Deque<JsonNode> stack = new ArrayDeque<>();
        stack.push(root);
        while (!stack.isEmpty()) {
            JsonNode node = stack.pop();
            if (node.isObject()) {
                for (JsonNode child : node) {
                    stack.push(child);
                }
            }
        }
    }

    // SAFE: recursive with depth guard
    static void parseSafely(JsonNode node, int depth) {
        if (depth > 100) throw new RuntimeException("Nesting too deep");
        if (node.isObject()) {
            for (JsonNode child : node) {
                parseSafely(child, depth + 1);
            }
        }
    }

    public static void main(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.*;

public class OOMDebug {

    static class Event {
        long timestamp;
        byte[] payload = new byte[1024]; // 1KB each
    }

    static Map<String, Event> eventCache = new HashMap<>();

    public static void main(String[] args) {
        // Simulate a memory leak: events are added but never removed
        for (int i = 0; i < 1_000_000; i++) {
            eventCache.put("event-" + i, new Event());
        }
        // 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;

public class ThreadSafety {

    static class SharedCounter {
        int count; // heap, shared, needs synchronization

        synchronized void increment() {
            count++; // safe with synchronized
        }
    }

    public static void main(String[] args) throws Exception {
        SharedCounter counter = new SharedCounter();

        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start(); t2.start();
        t1.join(); t2.join();

        System.out.println(counter.count); // always 2000 with synchronized
    }

    // Compare: local variable is thread-safe
    static void localSum() {
        int local = 0; // on this thread's stack
        for (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.
Commands
jmap -histo:live <pid> | head -30 # find dominant object types
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.
CharacteristicStackHeap
StoresLocal vars, params, referencesObjects, instance fields, arrays
Managed byJVM (LIFO, automatic)Garbage Collector
Size256KB–1MB per thread (default)Configurable with -Xmx (GBs)
SpeedVery fast (pointer increment)Slower (allocation + GC overhead)
LifetimeMethod execution scopeUntil no references remain
Error when fullStackOverflowErrorOutOfMemoryError
Thread accessPrivate per threadShared by all threads
GC involved?NoYes

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.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between stack and heap memory in Java?
02
Where do primitive variables live in Java?
03
What causes StackOverflowError?
04
How can I find a memory leak in production?
05
Can objects be allocated on the stack?
🔥

That's Stack & Queue. Mark it forged?

3 min read · try the examples if you haven't

Previous
Implement Stack using Queue
9 / 10 · Stack & Queue
Next
Monotonic Stack: Pattern, Use Cases and Coding Examples