Skip to content
Java Memory Model — Explained with Examples

Java Memory Model — Explained with Examples

DodaTech Updated Jun 15, 2026 7 min read

The Java Memory Model defines how threads interact through memory and how the JVM organizes runtime data into heap (shared) and stack (per-thread) areas, with garbage collection automatically reclaiming unreferenced objects.

Why the Memory Model Matters

Understanding Java’s memory model is essential for writing performant and stable applications. A poorly configured JVM can crash with OutOfMemoryError even with plenty of RAM. DodaTech’s services process millions of requests daily, and tuning GC settings reduced our p99 latency by 40%. Without this knowledge, developers write code that leaks memory, triggers frequent GC pauses, and wastes hardware resources.

Heap and Stack — Where Data Lives

Think of the heap as a public warehouse where everyone stores shared boxes. The stack is each worker’s personal desk — their own work area that’s cleaned up when they leave.

    graph TD
    subgraph JVM[<b>JVM Memory</b>]
        subgraph Heap[<b>Heap</b>]
            subgraph Young[<b>Young Generation</b>]
                Eden[Eden Space<br/>New objects]
                S0[Survivor S0<br/>Objects surviving 1st GC]
                S1[Survivor S1<br/>Objects surviving 2nd GC]
            end
            subgraph Old[<b>Old Generation</b>]
                Tenured[Tenured Space<br/>Long-lived objects]
            end
            subgraph Meta[<b>Metaspace</b>]
                ClassMeta[Class metadata, methods,<br/>constants, static variables]
            end
        end
        subgraph Stack[<b>Thread Stacks</b>]
            T1[Thread 1 Stack<br/>Local variables, frames]
            T2[Thread 2 Stack]
            T3[...Thread N Stack]
        end
        PC[Program Counter<br/>per thread]
        Native[Native Method Stacks]
    end
    Young --> |Minor GC<br/>promoted| Old
    Old --> |Major GC / Full GC<br/>G1, ZGC, Shenandoah| Meta
    style Heap fill:#3b82f6,color:#fff
    style Stack fill:#22c55e,color:#fff
  

Stack — Per-Thread Memory

Each thread gets its own stack containing:

  • Local variables (primitives and object references)
  • Method call frames (return address, parameters)
  • Stacks have fixed size (default 1MB, configurable with -Xss)
public class StackDemo {
    public static void main(String[] args) {
        recurse(1);
    }

    static void recurse(int depth) {
        int local = depth; // Stored on stack
        String msg = "Depth: " + depth; // Reference on stack, string in heap
        System.out.println(msg);
        try {
            recurse(depth + 1);
        } catch (StackOverflowError e) {
            System.out.println("Stack overflow at depth: " + depth);
        }
    }
}

Expected output:

Depth: 1
Depth: 2
...
Depth: 20183
Stack overflow at depth: 20184

Heap — Shared Memory

The heap is where all objects live. It’s shared across all threads and managed by the garbage collector.

import java.util.*;

public class HeapAllocationDemo {
    public static void main(String[] args) {
        Runtime rt = Runtime.getRuntime();
        System.out.println("Initial heap: " + (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024 + " MB");

        List<byte[]> chunks = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            chunks.add(new byte[10 * 1024 * 1024]); // 10 MB
            long used = (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024;
            System.out.println("After allocation " + (i + 1) + ": " + used + " MB used");
        }

        chunks.clear();
        System.gc(); // Request GC (not guaranteed)
        System.out.println("After GC: " + (rt.totalMemory() - rt.freeMemory()) / 1024 / 1024 + " MB used");
    }
}

Expected output (varies by JVM):

Initial heap: 5 MB
After allocation 1: 18 MB
After allocation 2: 28 MB
After allocation 3: 38 MB
...
After GC: 3 MB

Generational Garbage Collection

Most objects die young. The JVM exploits this by dividing the heap into generations.

Young Generation (Minor GC)

New objects go to Eden. When Eden fills up, a Minor GC runs. Live objects move to Survivor spaces (S0, S1). Objects that survive multiple Minor GCs get promoted to Old Generation.

import java.util.*;

public class GCPhenomenonDemo {
    // -Xms64m -Xmx64m -XX:+PrintGCDetails
    public static void main(String[] args) throws Exception {
        System.out.println("Allocating objects...");
        for (int i = 0; i < 100; i++) {
            byte[] data = new byte[512 * 1024]; // 512 KB
            Thread.sleep(10);
        }
        System.out.println("Done. Check GC log.");
    }
}

Expected output (with GC logging enabled):

Allocating objects...
[GC (Allocation Failure) -- 16384K->1024K(59392K), 0.002s]
[GC (Allocation Failure) -- 17408K->896K(59392K), 0.001s]
Done. Check GC log.

G1 Garbage Collector (Default Since Java 9)

G1 divides the heap into regions (1-32 MB) and prioritizes regions with the most garbage. It provides predictable pause times.

// Run with: -XX:+UseG1GC -Xms512m -Xmx512m -XX:MaxGCPauseMillis=200
import java.util.*;

public class G1GCDemo {
    public static void main(String[] args) throws Exception {
        List<int[]> list = new ArrayList<>();
        Random rand = new Random();

        System.out.println("G1 GC with 200ms pause target");
        System.out.println("Allocating random arrays...");

        for (int i = 0; i < 5000; i++) {
            int size = rand.nextInt(1000) + 100;
            list.add(new int[size]);
            if (i % 100 == 0) {
                Thread.sleep(1); // Let GC catch up
            }
            if (i % 50 == 0 && i > 0) {
                // Clear some references to create garbage
                for (int j = 0; j < 25; j++) {
                    list.set(rand.nextInt(list.size()), null);
                }
            }
        }
        System.out.println("Allocations complete. " + list.size() + " entries.");
    }
}

ZGC and Shenandoah — Low-Latency GCs

GCMax PauseHeap SizeBest For
G1~10-50msUp to 200GBGeneral purpose
ZGC<1msUp to 16TBLarge heaps, low latency
Shenandoah<1msUp to 16TBLow latency, concurrent compaction
# Run with ZGC
java -XX:+UseZGC -Xms4g -Xmx4g -jar application.jar

# Run with Shenandoah
java -XX:+UseShenandoahGC -Xms4g -Xmx4g -jar application.jar

Memory Leaks — The Silent Killer

Java’s GC handles memory, but it can’t collect objects that are still referenced but no longer needed. These are memory leaks.

import java.util.*;

public class MemoryLeakDemo {
    private static final Map<String, int[]> cache = new HashMap<>();

    public static void main(String[] args) throws Exception {
        System.out.println("Simulating a memory leak...");

        for (int i = 0; i < 1_000_000; i++) {
            // Objects accumulate in cache but are never removed
            cache.put("key-" + i, new int[100]);

            if (i % 100_000 == 0) {
                long used = (Runtime.getRuntime().totalMemory()
                    - Runtime.getRuntime().freeMemory()) / 1024 / 1024;
                System.out.println("Allocated " + i + ": " + used + " MB used");
                Thread.sleep(50);
            }
        }
    }
}

Expected output:

Simulating a memory leak...
Allocated 0: 16 MB used
Allocated 100000: 95 MB used
Allocated 200000: 175 MB used
...
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

Common Leak Patterns

  1. Static collections growing unbounded (as above)
  2. Unclosed resources (streams, connections, sockets)
  3. Inner class references holding outer class instances
  4. ThreadLocal variables not removed after use
  5. Listeners/callbacks registered but never unregistered

Analyzing Heap Dumps with JVisualVM

When your application runs out of memory, you need a heap dump — a snapshot of all live objects.

# Generate heap dump on OOM
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/dump.hprof -jar app.jar

# Generate heap dump manually
jmap -dump:live,format=b,file=/tmp/dump.hprof <pid>

# Analyze with JVisualVM
jvisualvm --openfd /tmp/dump.hprof
import java.util.*;
import javax.management.*;

public class HeapDumpDemo {
    public static void main(String[] args) throws Exception {
        System.out.println("PID: " + ManagementFactory.getRuntimeMXBean().getName());

        List<String[]> memoryHog = new ArrayList<>();
        for (int i = 0; i < 1_000_000; i++) {
            memoryHog.add(new String[500]);
            if (i % 10_000 == 0) {
                System.out.println("Allocated " + i + " arrays");
                Thread.sleep(10);
            }
        }
    }
}

Expected output:

PID: 12345@hostname
Allocated 0 arrays
Allocated 10000 arrays
...
// Take heap dump at this point: jmap -dump:live,format=b,file=/tmp/dump.hprof 12345

Common Mistakes

  1. Ignoring GC tuning: Default JVM settings work for development, not production. Always profile and tune for your workload.

  2. Creating too many threads: Each thread has a stack (~1MB). Thousands of threads = gigabytes of stack memory. Use thread pools.

  3. Holding references in static collections: Objects in static Map instances live forever. Implement eviction with WeakHashMap or caching libraries.

  4. Not sizing Metaspace: If your application dynamically generates classes (proxies, bytecode), Metaspace can grow unbounded. Set -XX:MaxMetaspaceSize.

  5. Calling System.gc() explicitly: It triggers a full GC and can pause the application for seconds. Let the JVM decide when to collect.

Practice Questions

  1. What is the difference between stack and heap memory?
  2. What happens during a Minor GC?
  3. How does ZGC achieve sub-millisecond pause times?
  4. What causes OutOfMemoryError: Java heap space?
  5. How do you find the source of a memory leak?

Answers:

  1. Stack is per-thread (local variables, method frames), automatically reclaimed when method exits. Heap is shared (all objects), managed by GC.
  2. Objects in Eden that are still reachable are moved to Survivor spaces. Objects that survive multiple Minor GCs are promoted to Old Generation. Eden is cleared.
  3. ZGC uses colored pointers, load barriers, and concurrent compaction — it doesn’t stop application threads for most operations.
  4. The heap is full and GC cannot reclaim enough space. Either increase heap size or fix a memory leak.
  5. Use heap dump analysis tools (JVisualVM, Eclipse MAT, YourKit). Look for objects with unexpectedly high counts or retention chains.

Mini Project: Profiling a Web Application

  1. Write a simple HTTP server that processes requests and caches results
  2. Intentionally introduce a memory leak (unbounded cache, unclosed streams, or ThreadLocal misuse)
  3. Run with GC logging: -Xlog:gc* -Xms256m -Xmx256m
  4. Use jvisualvm or jconsole to monitor heap usage
  5. Generate a heap dump on OOM
  6. Analyze the dump to find the leak source

This mirrors how DodaTech’s SRE team profiles services to ensure stable operation at scale.

Related topics: Java, JVM, GC, AOT, JIT

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro