JVM Memory Model & Garbage Collection

Heap generations, a survey of GC algorithms (G1, ZGC), what really causes OutOfMemoryError, and reading a GC log.

Why the heap is split into generations

The introduction page mentioned that the JVM manages memory automatically via a garbage collector, so you never call free() yourself. This page goes into how that actually works. Every major JVM garbage collector is built on the generational hypothesis: most objects die young — a loop variable, a temporary string, a request-scoped DTO — while a small minority (caches, connection pools, singletons) live for the entire run of the program. Instead of scanning the entire heap every time it needs to reclaim memory, the JVM splits the heap so it can scan the part that's almost always full of garbage far more often than the part that almost never is.

Plaintext
Heap
├── Young Generation
│   ├── Eden            <- nearly all new objects are allocated here
│   ├── Survivor Space S0
│   └── Survivor Space S1
└── Old Generation (Tenured)   <- long-lived objects end up here

Metaspace (off-heap, native memory)   <- class metadata, not subject to heap GC
  • Eden is where new objects are born. When Eden fills up, a minor GC runs: live objects are copied out to one of the two survivor spaces, and everything else in Eden is simply abandoned (no per-object cleanup — the entire region is reclaimed at once).
  • Objects that survive a minor GC get an age counter incremented each time; the two survivor spaces alternate which one is "active" so objects can be copied between them without ever mixing live and dead objects in the same space.
  • After surviving enough minor GCs (a JVM-tunable threshold, -XX:MaxTenuringThreshold, defaulting around 15), an object is promoted to the old generation — the assumption being that anything that's survived this long is probably going to stick around a while.
  • A major/full GC collects the old generation (and usually the young generation along with it), which is far more expensive since the old generation is typically much larger and has a much lower proportion of garbage to reclaim per pass.
  • Metaspace (replacing the old, notoriously leak-prone "PermGen" since Java 8) stores class metadata — loaded class definitions, method bytecode, constant pools — in native memory outside the regular heap, growing automatically by default rather than being a fixed size the way PermGen was.

A survey of garbage collection algorithms

Collector Strategy Pause behavior Best for
Serial GC Single-threaded, stop-the-world for both generations Simple but pauses scale with heap size Small heaps, single-core environments, simple CLI tools
Parallel GC ("throughput collector") Multiple threads collect in parallel, but the application still fully stops during collection Shorter pauses than Serial, still stop-the-world Batch/throughput-oriented workloads that can tolerate occasional pauses in exchange for maximum overall throughput
G1 (Garbage First) Heap is divided into many equal-sized regions; collects the regions with the most garbage first, tracked continuously Aims for a configurable pause-time goal (-XX:MaxGCPauseMillis), not a fixed algorithm-driven pause The default collector since Java 9 — general-purpose services with moderate-to-large heaps that want predictable pauses without hand-tuning
ZGC Region-based like G1, but nearly all work (including compaction) happens concurrently with the running application, using colored pointers and load barriers to track object moves without stopping every thread Sub-millisecond pauses, largely independent of heap size — proven on multi-terabyte heaps Latency-sensitive services where even G1's pauses are too disruptive, or heaps too large for stop-the-world compaction to be practical
Shenandoah Similar concurrent-compaction goal to ZGC, developed by Red Hat, ships in OpenJDK Also sub-millisecond, mostly-concurrent An alternative to ZGC with the same low-pause goal, different internal implementation

G1 became the default specifically because it doesn't require the manual young/old generation sizing that Parallel GC often needed to hit a target pause time — you instead give it a pause-time goal, and it adapts region collection to try to hit it. ZGC and Shenandoah exist because even G1's stop-the-world pauses (still typically tens of milliseconds under load) are too disruptive for the most latency-sensitive services; the trade-off for near-zero pauses is somewhat lower overall throughput and higher CPU/memory overhead from the extra bookkeeping concurrent collection requires.

What actually causes OutOfMemoryError in practice

OutOfMemoryError isn't one error — the message after it tells you which resource was actually exhausted, and the fix is completely different for each:

  • java.lang.OutOfMemoryError: Java heap space — the heap is genuinely full and a full GC couldn't free enough of it. Sometimes this is simply -Xmx set too low for a legitimately large workload; far more often in production it's a memory leak — objects that are unintentionally still reachable, so the GC (correctly) can't collect them. Classic causes: a static collection that only ever grows (a "cache" with no eviction), listeners/callbacks registered but never unregistered, or a ThreadLocal value never cleared on a thread that gets reused from a pool (the value silently outlives the request that created it).
  • java.lang.OutOfMemoryError: GC overhead limit exceeded — the JVM's own safety valve: it throws this when it's spent roughly 98% of recent CPU time on GC while recovering less than 2% of the heap each time. It's the JVM saying "I'm thrashing, not helping" rather than looping forever trying to free memory that isn't really garbage.
  • java.lang.OutOfMemoryError: Metaspace — too much class metadata loaded, typically from a classloader leak: something (often an application server redeploying a webapp without fully discarding its old classloader) keeps creating new classloaders whose classes are never unloaded, because something still holds a reference into the old classloader.
  • java.lang.OutOfMemoryError: Unable to create new native thread — not a heap problem at all; the process has hit the operating system's limit on threads (or the OS is out of native memory to allocate a new thread's stack). Common in a service that spawns unbounded threads per request instead of using a bounded pool.
  • StackOverflowError (not OutOfMemoryError, but the other common one) — a single thread's own call stack, not the shared heap, ran out of space, almost always from unbounded or runaway recursion. Different memory region, different cause, different fix (usually: add a base case, or rewrite iteratively).

A minimal example of the first, most common case — a "cache" with no eviction:

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

public class LeakyCache {
    // Never removed from, never bounded — every request adds one more entry forever.
    private static final List<byte[]> cache = new ArrayList<>();

    public void handleRequest(byte[] payload) {
        cache.add(payload);
    }
}

The fix is to bound it — either cap its size explicitly, or use a real caching library (Caffeine, Guava's CacheBuilder) that supports size- or time-based eviction out of the box:

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

public class BoundedCache {
    private static final int MAX_ENTRIES = 1_000;

    // removeEldestEntry turns a LinkedHashMap into a simple, bounded LRU cache.
    private static final Map<String, byte[]> cache = new LinkedHashMap<>(16, 0.75f, true) {
        @Override
        protected boolean removeEldestEntry(Map.Entry<String, byte[]> eldest) {
            return size() > MAX_ENTRIES;
        }
    };

    public void handleRequest(String key, byte[] payload) {
        cache.put(key, payload);
    }
}

Reading a basic GC log

Modern JVMs (9+) use unified logging — enable a basic GC log with -Xlog:gc:

Bash
java -Xlog:gc -Xmx512m -Xms512m MyApp
Plaintext
[0.523s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 128M->16M(512M) 12.345ms
[2.145s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 144M->24M(512M) 14.892ms
[5.667s][info][gc] GC(5) Pause Full (System.gc()) 380M->40M(512M) 145.221ms

Reading one of these lines left to right: [2.145s] is wall-clock time since JVM start; GC(1) numbers each collection sequentially; Pause Young (Normal) (G1 Evacuation Pause) names the collection type — a routine minor GC copying live objects out of Eden; 144M->24M(512M) is heap usage before the collection, after it, and the total capacity — so this collection reclaimed 120MB; 14.892ms is how long the application was actually paused for it.

The last line is the one worth reacting to: Pause Full collections are dramatically more expensive (145ms here, versus ~13ms for the young collections above) because they scan the entire heap including the old generation. A Pause Full triggered explicitly by System.gc() (as shown) is often just a debugging call left in somewhere, or a library being conservative — but repeated, unprompted full GCs in a production log are a real signal: either the old generation is genuinely filling up faster than it should (worth investigating with a heap dump), or the heap is simply undersized for the live-object volume the application actually holds onto.

Common mistakes

  • Reaching for GC tuning flags before ever looking at a GC log or a profiler — most "GC problems" are actually allocation or leak problems that no amount of flag-tuning fixes.
  • Treating a bigger -Xmx as a fix for OutOfMemoryError: Java heap space caused by a real leak — it only delays the crash (and can make individual GC pauses longer once they do happen), it doesn't address the growing set of unintentionally-reachable objects.
  • Confusing StackOverflowError (one thread's call stack, from deep/unbounded recursion) with OutOfMemoryError (the shared heap or metaspace) — they're different memory regions with different root causes and different fixes.
  • Assuming G1 needs the same manual young/old generation sizing that Parallel GC often did — G1 is designed around a pause-time goal instead, and hand-tuning generation sizes usually fights against its own adaptive sizing rather than helping it.
  • Shrugging off repeated Pause Full entries in a production GC log as "just a pause" — they're the strongest single signal in a GC log that something (an undersized old generation, or a genuine leak) needs investigating.

Interview questions

Q: Why does the JVM separate the heap into young and old generations instead of using one region? Because most objects die almost immediately (the generational hypothesis) — collecting a small, mostly-garbage young generation frequently is far cheaper than repeatedly scanning the entire heap, including long-lived objects that are essentially never garbage. Objects that survive enough young collections are promoted to the old generation, which is collected far less often.

Q: What's the practical difference between G1 and ZGC? G1 is the general-purpose default: it targets a configurable pause-time goal but still stops the application (briefly) during each collection. ZGC does almost all of its work, including compaction, concurrently with the running application using colored pointers and load barriers, trading some throughput and memory overhead for pause times that stay in the sub-millisecond range even on very large heaps.

Q: A production service's GC log shows repeated "Pause Full" entries not triggered by an explicit System.gc() call — what does that suggest, and what would you check next? It suggests the old generation is filling up faster than expected — either the heap is undersized for the application's real working set, or there's a genuine memory leak (objects unintentionally still reachable). The next step is a heap dump analysis (or a profiler) to see what's actually accumulating in the old generation, rather than reaching for GC tuning flags first.