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.
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:
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
- Stack memory is method-scoped and free; heap memory is shared and collected.
- Most objects die young — the JVM is built around that assumption, so short-lived objects are cheap.
- Leaks in Java are reachability leaks: something you forgot still holds a reference.
- Metaspace is native memory for class metadata; leaking classloaders leaks metaspace.
- 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.