Skip to main content
Java · lesson 40 of 66
Open the Java page →
Java advanced Lesson 40 of 66

JVM Memory Model

See JVM memory at work: stack frames and StackOverflowError, heap limits with -Xmx, OutOfMemoryError, garbage collectors, weak references and leaks.

By SkillByExample · Updated

The JVM splits memory into a stack per thread (method frames and local variables), one shared heap (every object), and Metaspace (class metadata, in native memory). Every example is a single file run with the JDK 21 source launcher (java File.java); JVM flags go before the file name.

Stack frames and StackOverflowError

Each method call pushes a frame onto the calling thread’s stack. Recursion without a base case keeps pushing until the stack is full.

public class StackDepth {
    static int depth = 0;

    static void recurse() {
        depth++;
        recurse();               // no base case
    }

    public static void main(String[] args) {
        try {
            recurse();
        } catch (StackOverflowError e) {
            System.out.println("caught " + e.getClass().getSimpleName());
            System.out.println("more than 1,000 frames deep? " + (depth > 1_000));
        }
    }
}
$ java StackDepth.java
caught StackOverflowError
more than 1,000 frames deep? true

The exact depth depends on frame size and the -Xss stack size (512 KB to 1 MB by default, per thread), so the program only checks it was large. StackOverflowError is an Error, not an Exception — catching it is fine for a demo, but in real code it means a bug.

References on the stack, objects on the heap

A local variable of object type holds a reference; the object itself lives on the heap. Java passes that reference by value.

import java.util.ArrayList;
import java.util.List;

public class RefDemo {
    static void addItem(List<String> list) {
        list.add("added");          // mutates the shared heap object
    }

    static void reassign(List<String> list) {
        list = new ArrayList<>();   // changes only this frame's copy
        list.add("lost");
    }

    public static void main(String[] args) {
        List<String> items = new ArrayList<>();
        addItem(items);
        reassign(items);
        System.out.println(items);
    }
}
$ java RefDemo.java
[added]

Both methods received a copy of the reference. addItem followed it to the heap object main also sees; reassign pointed its own copy at a new list, which became garbage as soon as its frame was popped.

Sizing the heap with -Xmx

Runtime reports the heap as the running JVM sees it. -Xmx sets the maximum.

public class HeapSize {
    public static void main(String[] args) {
        Runtime rt = Runtime.getRuntime();
        long mb = 1024 * 1024;
        System.out.println("max heap: " + rt.maxMemory() / mb + " MB");
        System.out.println("processors: " + (rt.availableProcessors() > 0));
    }
}
$ java -Xmx256m HeapSize.java
max heap: 256 MB
processors: true

Without -Xmx the JVM picks a default of a quarter of physical (or container) memory. Setting -Xms equal to -Xmx stops the heap from resizing at runtime, a common choice for servers.

OutOfMemoryError: Java heap space

A collection that only grows is the classic leak. With a deliberately small heap it fails fast.

import java.util.ArrayList;
import java.util.List;

public class Leak {
    static final List<byte[]> CACHE = new ArrayList<>();   // never evicted

    public static void main(String[] args) {
        try {
            while (true) {
                CACHE.add(new byte[1024 * 1024]);           // 1 MB per entry
            }
        } catch (OutOfMemoryError e) {
            int held = CACHE.size();
            CACHE.clear();                                  // release before printing
            System.out.println(e.getMessage());
            System.out.println("held fewer than 32 MB? " + (held < 32));
        }
    }
}
$ java -Xmx32m Leak.java
Java heap space
held fewer than 32 MB? true

Every array stays reachable through the static CACHE, so the collector can free nothing and the JVM gives up with Java heap space. In production add -XX:+HeapDumpOnOutOfMemoryError and open the .hprof file in Eclipse MAT or VisualVM to see what is holding the memory.

Which garbage collector is running

The GarbageCollectorMXBeans name the collector the JVM chose; a flag picks a different one.

import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;

public class WhichGc {
    public static void main(String[] args) {
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            System.out.println(gc.getName());
        }
    }
}
$ java -XX:+UseG1GC WhichGc.java && java -XX:+UseParallelGC WhichGc.java && java -XX:+UseZGC -XX:+ZGenerational WhichGc.java
G1 Young Generation
G1 Concurrent GC
G1 Old Generation
PS MarkSweep
PS Scavenge
ZGC Minor Cycles
ZGC Minor Pauses
ZGC Major Cycles
ZGC Major Pauses

G1 (the default on machines with 2+ CPUs and 2+ GB) collects the young generation often and the old generation in concurrent cycles. Parallel GC uses stop-the-world collections tuned for throughput. Generational ZGC does nearly all its work concurrently, keeping pauses under a millisecond.

Weak references let the collector win

A WeakHashMap holds its keys weakly: once nothing else references a key, its entry may vanish at the next collection.

import java.util.Map;
import java.util.WeakHashMap;

public class WeakCache {
    public static void main(String[] args) throws InterruptedException {
        Map<Object, String> cache = new WeakHashMap<>();
        Object kept = new Object();
        Object dropped = new Object();
        cache.put(kept, "kept");
        cache.put(dropped, "dropped");
        System.out.println("before gc: " + cache.size());

        dropped = null;           // last strong reference gone
        System.gc();              // a request, honoured by default on HotSpot
        Thread.sleep(100);
        System.out.println("after gc:  " + cache.size() + " " + cache.values());
        System.out.println(kept != null);
    }
}
$ java WeakCache.java
before gc: 2
after gc:  1 [kept]
true

kept is still strongly referenced by a local variable, so its entry survives. SoftReference is the gentler cousin — cleared only when memory runs low — and PhantomReference (or the java.lang.ref.Cleaner API) supports post-mortem cleanup.

A bounded cache instead of a leak

The fix for an ever-growing map is an eviction policy. LinkedHashMap.removeEldestEntry gives one in a few lines.

import java.util.LinkedHashMap;
import java.util.Map;

public class BoundedCache {
    public static void main(String[] args) {
        Map<String, Integer> cache = new LinkedHashMap<>() {
            @Override
            protected boolean removeEldestEntry(Map.Entry<String, Integer> eldest) {
                return size() > 3;
            }
        };
        for (String k : new String[] {"a", "b", "c", "d", "e"}) {
            cache.put(k, k.charAt(0) - 'a');
            System.out.println(cache);
        }
    }
}
$ java BoundedCache.java
{a=0}
{a=0, b=1}
{a=0, b=1, c=2}
{b=1, c=2, d=3}
{c=2, d=3, e=4}

After each put, the map asks whether to drop its oldest entry, so it never holds more than three. For production caches, Caffeine adds size, time and weight limits with better hit rates.

Practice

  1. Turn BoundedCache into an LRU cache: reading a key should make it the most recently used, so it is evicted last. Insert a, b, c, read a, then insert d and print the keys.
Solution
import java.util.LinkedHashMap;
import java.util.Map;

public class LruCache {
    public static void main(String[] args) {
        Map<String, Integer> cache = new LinkedHashMap<>(16, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<String, Integer> eldest) {
                return size() > 3;
            }
        };
        cache.put("a", 1);
        cache.put("b", 2);
        cache.put("c", 3);
        cache.get("a");                 // a becomes most recently used
        cache.put("d", 4);              // evicts b, the least recently used
        System.out.println(cache.keySet());
    }
}
$ java LruCache.java
[c, a, d]

The third constructor argument, accessOrder = true, reorders entries on every get, so the “eldest” entry is the least recently used rather than the first inserted.

  1. An EventSource keeps listeners in a list. Register three listeners, unregister one, and fire an event to show the removed listener is no longer held (and no longer called).
Solution
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;

public class Listeners {
    static final List<Consumer<String>> listeners = new ArrayList<>();

    public static void main(String[] args) {
        Consumer<String> a = e -> System.out.println("a got " + e);
        Consumer<String> b = e -> System.out.println("b got " + e);
        Consumer<String> c = e -> System.out.println("c got " + e);
        listeners.add(a);
        listeners.add(b);
        listeners.add(c);
        listeners.remove(b);           // forgetting this is the leak
        System.out.println("held: " + listeners.size());
        listeners.forEach(l -> l.accept("saved"));
    }
}
$ java Listeners.java
held: 2
a got saved
c got saved

A listener that is never removed keeps itself, and everything it captures, reachable for as long as the source lives.

  1. Print the heap’s maximum size using ManagementFactory.getMemoryMXBean() instead of Runtime, and run it with -Xmx64m.
Solution
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryUsage;

public class HeapBean {
    public static void main(String[] args) {
        MemoryUsage heap = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage();
        System.out.println("max heap: " + heap.getMax() / (1024 * 1024) + " MB");
        System.out.println("used <= committed? " + (heap.getUsed() <= heap.getCommitted()));
    }
}
$ java -Xmx64m HeapBean.java
max heap: 64 MB
used <= committed? true

The same bean exposes getNonHeapMemoryUsage(), which covers Metaspace and the JIT code cache.

  1. Catch a StackOverflowError from a recursive method, then show that the same thread can still call a normal method afterwards — the stack unwinds when the error propagates.
Solution
public class Recover {
    static int down(int n) { return down(n + 1); }

    static int sum(int a, int b) { return a + b; }

    public static void main(String[] args) {
        try {
            down(0);
        } catch (StackOverflowError e) {
            System.out.println("overflowed");
        }
        System.out.println("after: " + sum(2, 3));
    }
}
$ java Recover.java
overflowed
after: 5

Every frame between main and the overflow point is popped as the error propagates, so the stack is almost empty again by the time catch runs.

Frequently Asked Questions

What is the difference between heap and stack memory in Java?
Stack memory holds method call frames: local variables, parameters and return addresses. Each thread has its own stack and frames are freed automatically when a method returns. Heap memory holds every object created with new; it is shared by all threads and reclaimed by the garbage collector.
What is Metaspace and how is it different from PermGen?
Metaspace (Java 8+) stores class metadata such as class structures and method bytecode. It replaced PermGen, which had a fixed maximum size and caused OutOfMemoryError: PermGen space. Metaspace lives in native memory and grows as needed, though you can cap it with -XX:MaxMetaspaceSize.
What is a memory leak in Java?
A Java memory leak is an object the program no longer needs but still references, so the garbage collector cannot reclaim it. Common causes are static collections that only grow, listeners that are never removed, ThreadLocal values that are never cleared, and caches without an eviction policy.
Which garbage collector should I use?
G1 is the default on server-class machines and is a sound choice for most applications. ZGC (generational since JDK 21) keeps pauses under a millisecond even on very large heaps, which suits latency-sensitive services. Parallel GC maximises throughput for batch jobs where pause times do not matter.
Does System.gc() force a garbage collection?
No, it is only a request. On HotSpot with the default collectors it usually triggers a full collection, but the JVM may ignore it, and -XX:+DisableExplicitGC turns it into a no-op. Production code should never rely on it; it is useful only in demos and tests.