Garbage collection and the heap

Java · Expert · 16 min

Latency spikes, OutOfMemoryErrors and container memory limits all come down to understanding the heap and the collector.

Objects live on the heap; local variables and call frames live on per-thread stacks. An object is eligible for collection when it is no longer reachable from GC roots — thread stacks, static fields, JNI references. Cycles don't matter; reachability does.

Most collectors are generational, based on the observation that most objects die young. New objects go to the young generation, collected often and cheaply by copying the few survivors; objects that survive several collections are promoted to the old generation, collected less often.

HotSpot collectors in Java 21:

  • G1 (default) — divides the heap into regions, collects mostly concurrently, aims at a pause-time goal (-XX:MaxGCPauseMillis, default 200 ms). A good default for most services.
  • ZGC (-XX:+UseZGC) — almost fully concurrent, pauses typically under a millisecond regardless of heap size. Java 21 added generational ZGC via -XX:+ZGenerational, which later became the only mode.
  • Parallel (-XX:+UseParallelGC) — maximum throughput with longer stop-the-world pauses; good for batch jobs.
  • Serial — single-threaded, for tiny heaps and small containers.

Size the heap with -Xmx / -Xms, or in containers with -XX:MaxRAMPercentage=75. Leave headroom: thread stacks, metaspace, code cache and direct buffers live outside the heap.

A Java memory leak is an object kept reachable by accident: an ever-growing static map or cache, listeners never removed, ThreadLocals on pooled threads. The symptom is old-generation usage that climbs after each collection until OutOfMemoryError: Java heap space. Use -XX:+HeapDumpOnOutOfMemoryError and analyse the dump's dominator tree to find what holds the memory.

Turn on GC logging in production with -Xlog:gc*:file=gc.log — it's cheap and invaluable.

Example

import java.lang.management.ManagementFactory;
import java.util.HashMap;
import java.util.Map;

// Run with: java -XX:+UseG1GC -Xmx64m -Xlog:gc Leak.java
public class Leak {
    // A static map is a GC root path: everything in it stays reachable forever.
    static final Map<Integer, byte[]> CACHE = new HashMap<>();

    public static void main(String[] args) {
        for (var gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            System.out.println("collector: " + gc.getName());   // e.g. G1 Young Generation
        }
        for (int i = 0; i < 1_000; i++) {
            byte[] temp = new byte[1_000_000];   // short-lived: reclaimed by young GCs
            if (i % 50 == 0) CACHE.put(i, temp); // retained: old gen keeps growing
        }
        Runtime rt = Runtime.getRuntime();
        long usedMb = (rt.totalMemory() - rt.freeMemory()) / 1_000_000;
        System.out.println("used ~" + usedMb + " MB, max " + rt.maxMemory() / 1_000_000 + " MB");
        // With -Xmx64m, raise the cached share and you'll hit OutOfMemoryError: Java heap space
    }
}

Remember

  • Objects are collected when unreachable from GC roots — cycles don't leak
  • G1 is the default; ZGC for very low latency, Parallel for throughput
  • Leaks are accidental references (static maps, listeners, ThreadLocals); diagnose with heap dumps and GC logs

Watch out for

  • Setting -Xmx equal to the container memory limit, leaving no room for non-heap memory and getting OOM-killed.

Check yourself

  1. Two objects reference each other but nothing else references either. What happens?
  2. A latency-sensitive service with a 32 GB heap suffers 300 ms pauses. Which change is most likely to help?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.oracle.com/en/java/javase/21/gctuning/