Skip to content
elephantoo

JVM memory & garbage collection

Lesson 38 of 43 15 min read

Stack vs heap, class loading, generations, G1/ZGC, memory leaks, heap flags and diagnostic tools.


Java manages memory for you: you create objects with new, and the garbage collector (GC) frees them when they are no longer needed. You never call free(). But "automatic" doesn't mean "magic". Understanding how the JVM organises memory helps you explain StackOverflowError and OutOfMemoryError, avoid memory leaks, tune applications, and answer a classic interview topic.

The big picture#

Output
┌───────────────────────── JVM process ─────────────────────────┐
│  HEAP (shared by all threads)          METASPACE (native)     │
│  ┌─────────────────────────────┐       class metadata,        │
│  │ Young gen: Eden | Survivors │       method bytecode        │
│  │ Old gen (tenured)           │                              │
│  └─────────────────────────────┘       CODE CACHE: JIT output │
│                                                               │
│  Per thread: STACK (frames: locals, operands) + PC register   │
└───────────────────────────────────────────────────────────────┘
AreaHoldsShared?Error when full
Heapall objects and arraysyes, by all threadsOutOfMemoryError: Java heap space
Stack (one per thread)method frames: local variables, parameters, return addressesnoStackOverflowError
Metaspaceclass definitions, method metadatayesOutOfMemoryError: Metaspace
Code cachenative code compiled by the JITyesJIT stops compiling (warning)

Stack vs heap in action#

StackHeap.java
public class StackHeap {
    record Order(String id, double amount) { }

    static double withTax(Order order) {        // new frame: parameter 'order' (a reference)
        double rate = 0.18;                      // primitive local: lives in this frame
        return order.amount() * (1 + rate);
    }

    public static void main(String[] args) {
        int quantity = 3;                        // primitive in main's frame
        Order o = new Order("A-1", 1000);        // reference in the frame, object on the heap
        Order alias = o;                         // second reference to the SAME heap object
        double total = withTax(o) * quantity;
        System.out.printf("%.1f %b%n", total, alias == o);
    }   // main's frame is popped; the Order object becomes unreachable
}
Output
3540.0 true
Output
 main thread stack                     heap
┌────────────────────┐
│ withTax frame      │
│   order ───────────┼──┐
│   rate = 0.18      │  │      ┌──────────────────────┐
├────────────────────┤  ├────► │ Order("A-1", 1000.0) │
│ main frame         │  │      └──────────────────────┘
│   quantity = 3     │  │
│   o ───────────────┼──┤
│   alias ───────────┼──┘
└────────────────────┘
  • Each method call pushes a frame; returning pops it. Stack memory is reclaimed instantly and automatically.
  • Primitives and references that are local variables live in the frame; the objects they point to live on the heap. (Fields of an object, even primitive ones, live inside the object on the heap.)
  • Each thread has its own stack, which is why local variables are inherently thread-safe, while heap objects are shared.

StackOverflowError#

Each thread's stack has a fixed size (often 512 KB to 1 MB by default; set with -Xss). Infinite or very deep recursion exhausts it:

Overflow.java
public class Overflow {
    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("StackOverflowError after roughly " + (depth > 1000 ? "thousands of" : "a few") + " calls");
        }
    }
}
Output
StackOverflowError after roughly thousands of calls

The exact depth depends on frame size and JVM settings. The fix is almost always a missing base case, or converting deep recursion into a loop.

How garbage collection works#

An object is garbage when no chain of references leads to it from a GC root. GC roots include:

  • local variables and parameters on active threads' stacks;
  • static fields of loaded classes;
  • active threads themselves, and JNI references.
Reachability.java
import java.util.ArrayList;
import java.util.List;

public class Reachability {
    static List<byte[]> cache = new ArrayList<>();      // a static field is a GC root

    public static void main(String[] args) {
        byte[] temp = new byte[1024];   // reachable via the local 'temp'
        temp = null;                    // now unreachable: eligible for GC

        cache.add(new byte[1024]);      // reachable via the static list: never collected
                                        // until removed from the list

        Object a = new Object();
        Object b = a;
        a = null;                       // still reachable through b!
        System.out.println("b still alive: " + (b != null) + ", cache size: " + cache.size());
    }
}
Output
b still alive: true, cache size: 1

Cycles are not a problem: if objects A and B reference each other but nothing reachable references either, both are collected. Java uses tracing (mark reachable objects, reclaim the rest), not reference counting.

Generational collection#

Most objects die young: temporary strings, iterators, lambdas, request objects. The JVM exploits this weak generational hypothesis:

  1. New objects are allocated in Eden (part of the young generation). Allocation is extremely fast, basically bumping a pointer.
  2. When Eden fills, a minor GC copies the few surviving objects into a survivor space and wipes Eden in one go.
  3. Objects that survive several minor GCs are promoted to the old generation.
  4. The old generation is collected less often, by a major/mixed collection, which is more expensive.

Minor GCs are quick because they touch only live objects, and in the young generation there are few of those.

Garbage collectors in modern Java#

CollectorFlagStrengths
G1 (default since Java 9)-XX:+UseG1GCbalanced throughput and pause times; splits the heap into regions; good default for most apps
ZGC-XX:+UseZGC (generational by default since Java 23; use -XX:+ZGenerational on 21)sub-millisecond pauses even with huge heaps (terabytes)
Shenandoah-XX:+UseShenandoahGClow pause times, concurrent compaction (in many OpenJDK builds)
Parallel-XX:+UseParallelGCmaximum throughput for batch jobs; longer pauses
Serial-XX:+UseSerialGCsingle-threaded; tiny heaps and small containers

GCs pause application threads ("stop-the-world") for some phases; modern collectors do most work concurrently to keep pauses short. For most applications: stick with G1, set a sensible -Xmx, and only switch collectors when measurements show a need (e.g. ZGC for latency-sensitive services).

Key JVM memory flags#

Terminal
java -Xms512m -Xmx2g -Xss1m -XX:+UseG1GC -jar app.jar
FlagMeaning
-Xms512minitial heap size
-Xmx2gmaximum heap size
-Xss1mstack size per thread
-XX:MaxMetaspaceSize=256mcap class metadata
-XX:MaxRAMPercentage=75max heap as a % of container memory (great for Docker/Kubernetes)
-XX:+HeapDumpOnOutOfMemoryErrorwrite a heap dump when OOM happens
-Xlog:gclog GC events (unified logging, Java 9+)

Seeing memory from code#

MemoryInfo.java
public class MemoryInfo {
    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("total heap: " + rt.totalMemory() / mb + " MB");
        System.out.println("free heap:  " + rt.freeMemory() / mb + " MB");
        System.out.println("processors: " + rt.availableProcessors());
    }
}

Run it with different limits to see the effect:

Terminal
java -Xmx256m MemoryInfo.java
Output
max heap:   256 MB
total heap: 254 MB
free heap:  234 MB
processors: 8

(Your numbers will differ.) System.gc() only suggests a collection. Production code should almost never call it.

OutOfMemoryError and memory leaks#

Java can't leak memory the C way, but it can retain objects you no longer need: if something reachable keeps referencing them, the GC must keep them. Classic leak sources:

  • static collections that only grow (caches without eviction);
  • listeners or callbacks registered and never removed;
  • ThreadLocal values in pooled threads that are never cleared;
  • unclosed resources (streams, connections) holding buffers;
  • huge HashMaps keyed by objects with broken equals/hashCode, so "duplicates" pile up.
Leak.java
import java.util.ArrayList;
import java.util.List;

public class Leak {
    private static final List<byte[]> HISTORY = new ArrayList<>();   // grows forever

    static void handleRequest() {
        byte[] buffer = new byte[1024 * 1024];   // 1 MB per "request"
        HISTORY.add(buffer);                      // oops: kept forever
    }

    public static void main(String[] args) {
        int requests = 0;
        try {
            while (true) {
                handleRequest();
                requests++;
            }
        } catch (OutOfMemoryError e) {
            int handled = requests;
            HISTORY.clear();                      // release memory so we can keep running
            System.out.println("OutOfMemoryError after about " + (handled / 10 * 10) + " requests");
        }
    }
}
Terminal
java -Xmx64m Leak.java
Output
OutOfMemoryError after about 30 requests

The exact number depends on the JVM version and collector. With G1, each 1 MB array counts as a "humongous" object and uses more than its own size in heap regions, so 64 MB fills up after roughly 30 buffers.

Fixes: bound caches (an LRU LinkedHashMap, or a library like Caffeine), remove listeners, clear ThreadLocals, and use try-with-resources.

Reference types for caches

java.lang.ref offers references the GC treats specially:

  • WeakReference<T>: doesn't keep the object alive; cleared at the next GC once only weakly reachable. WeakHashMap uses weak keys.
  • SoftReference<T>: cleared only when memory is tight; sometimes used for memory-sensitive caches.

Diagnostic tools that come with the JDK#

Terminal
jps -l                                   # list running Java processes and their IDs
jcmd <pid> GC.heap_info                  # heap summary
jcmd <pid> GC.class_histogram | head     # which classes use the most memory
jcmd <pid> GC.heap_dump /tmp/heap.hprof  # full heap dump for analysis
jcmd <pid> Thread.print                  # thread dump (also: jstack <pid>)
jstat -gcutil <pid> 1000                 # GC statistics every second
jcmd <pid> JFR.start duration=60s filename=rec.jfr   # Java Flight Recorder

Open heap dumps with Eclipse MAT or VisualVM, and flight recordings with JDK Mission Control, to find which objects dominate memory and who holds onto them.

The JIT compiler, briefly#

The JVM starts by interpreting bytecode, profiles which methods are hot, and compiles them to optimised machine code (the C1 and C2 compilers, together called tiered compilation). Optimisations include inlining, loop unrolling and escape analysis, which can avoid heap allocation entirely for objects that never escape a method. That's why Java programs get faster after warming up, and why micro-benchmarks need a tool like JMH.

Practical guidelines#

  • Set -Xmx (or MaxRAMPercentage in containers) explicitly in production.
  • Prefer the default G1 unless measurements say otherwise.
  • Don't prematurely optimise allocation; short-lived objects are cheap. Avoid retaining objects, not creating them.
  • Enable -XX:+HeapDumpOnOutOfMemoryError so failures leave evidence.
  • Monitor GC pause times and heap usage (JFR, Micrometer/Prometheus in Spring Boot).

Common mistakes#

  • Believing obj = null immediately frees memory.
  • Calling System.gc() to "fix" memory problems.
  • Unbounded static caches and collections.
  • Giving a container 1 GB of RAM and the JVM -Xmx1g. Leave room for Metaspace, thread stacks and native memory.
  • Confusing StackOverflowError (recursion depth) with OutOfMemoryError (heap).

What's next#

Next, we connect Java to a real database: JDBC with MySQL.

Check your understanding

Quick quiz

0/3 answered
  1. 1.In void m() { Order o = new Order(); }, where do the variable o and the Order object live?

  2. 2.When is an object eligible for garbage collection?

  3. 3.Which flag sets the maximum heap size to 2 GB?

Finished reading?

Mark this lesson complete to track your progress.