Every Java developer eventually hits a moment where the JVM stops being a black box: an OutOfMemoryError in production, a service that pauses for seconds at a time, or a heap dump that needs explaining. This article builds the mental model you need for those moments.

The Two Worlds: Stack and Heap

The JVM divides working memory into two fundamentally different regions.

Stack memory is per-thread. Every time a method is invoked, the JVM pushes a stack frame containing the method's local variables, its operand stack, and a reference to the constant pool of its class. When the method returns, the frame is popped and that memory is gone — no garbage collector involved.

Heap memory is shared across all threads and holds every object you create with new. References live on the stack; the objects they point to live on the heap.

java
public class OrderService {

    public BigDecimal totalFor(List<OrderLine> lines) {
        BigDecimal total = BigDecimal.ZERO;   // reference on the stack
        for (OrderLine line : lines) {
            total = total.add(line.price());  // new object on the heap each iteration
        }
        return total;
    }
}

In the example above, total is a stack-local reference, but each intermediate BigDecimal produced by add is a heap allocation. This is why hot loops that churn immutable objects can put real pressure on the garbage collector.

Inside the Heap: Generations

Modern collectors organize the heap around one empirical observation, the weak generational hypothesis: most objects die young.

  • Eden — where almost all objects are born. Allocation here is nearly free: a pointer bump in a thread-local allocation buffer (TLAB).
  • Survivor spaces — objects that survive a young collection are copied here, back and forth, with an age counter.
  • Old generation — objects that survive enough young collections are promoted (tenured) here.

Young collections are frequent and cheap because they only touch a small, mostly-dead region. Old-generation collections are rarer and more expensive.

Metaspace

Class metadata — the runtime representation of your classes, method bytecode, and constant pools — lives in metaspace, which is allocated from native memory rather than the heap. It grows on demand, and by default is limited only by available native memory.

A leak here usually means classloaders are being created and never released, a classic problem in application servers doing hot redeploys. The symptom is java.lang.OutOfMemoryError: Metaspace.

How Garbage Collection Actually Works

A garbage collector answers one question: which objects are still reachable? It starts from the GC roots — thread stacks, static fields, JNI references — and traces every reference it can reach. Everything else is garbage by definition.

The important consequence: Java does not collect objects "when nothing uses them anymore." It collects them when a GC cycle runs and they are unreachable from the roots. A single accidental reference from a long-lived collection is enough to keep an entire object graph alive:

java
public class SessionRegistry {

    // Grows forever if sessions are never removed — a textbook leak.
    private static final Map<String, UserSession> SESSIONS = new HashMap<>();

    public static void register(UserSession session) {
        SESSIONS.put(session.id(), session);
    }
}

Choosing a Collector

The JDK ships several collectors, and the default (G1) is a good general-purpose choice:

Collector Optimizes for Typical use
Serial Footprint Small heaps, containers with one CPU
Parallel Throughput Batch jobs where pauses don't matter
G1 Balanced pauses Most services (default since JDK 9)
ZGC Sub-millisecond pauses Large heaps, latency-critical services

Before tuning anything, measure. Enable GC logging with -Xlog:gc* and look at pause times and allocation rates. The majority of "GC problems" are allocation problems in application code wearing a disguise.

Practical Takeaways

  1. Stack memory is method-scoped and free; heap memory is shared and collected.
  2. Most objects die young — the JVM is built around that assumption, so short-lived objects are cheap.
  3. Leaks in Java are reachability leaks: something you forgot still holds a reference.
  4. Metaspace is native memory for class metadata; leaking classloaders leaks metaspace.
  5. Measure with GC logs before touching a single flag.

Once this model is in place, heap dumps and GC logs stop being intimidating — they become a map of exactly what your application is doing with memory.