JVM Memory & Garbage Collection: What Java Developers Need to Know
Every Java interview eventually lands here: "heap vs stack?", "how does GC work?" Most answers are memorized definitions. This post gives you the mental model that makes the definitions obvious — where objects live, what the collector actually does, and the one rule that matters most: diagnose before you tune.
1. Heap vs stack vs metaspace: three neighborhoods
The JVM splits memory by lifetime and purpose, not by type:
- Stack — one per thread. Holds local variables and call frames. Memory is freed the instant a method returns. Small, fast, and never garbage collected — a
StackOverflowErrormeans runaway recursion, not a leak. - Heap — one shared region. Every
newobject lands here. This is the only region the garbage collector manages. AOutOfMemoryError: Java heap spacemeans the heap filled with objects that are still reachable. - Metaspace — class metadata (loaded classes, method bytecode). Grows as classes load; a leak here (
OutOfMemoryError: Metaspace) usually means a classloader leak — common with hot redeploys, not with normal code.
public class MemoryHood {
static String shared = "heap: one copy, shared by all threads"; // heap (static field)
public static void main(String[] args) {
int count = 3; // stack: dies when main() returns
String greeting = new String("hi"); // stack holds the REFERENCE; "hi" lives on the HEAP
helper(greeting);
}
static void helper(String s) {
int len = s.length(); // stack: new frame, new locals, freed on return
System.out.println(len + " " + shared);
}
}
2 heap: one copy, shared by all threads
The decision rule: when you see an OutOfMemoryError, the suffix tells you which neighborhood to investigate — Java heap space (reachable objects piling up), Metaspace (classloader leak), Unable to create new native thread (thread/stack exhaustion, not the heap at all).
2. How garbage collection works: the generational bet
The GC's entire design rests on one observed fact — the generational hypothesis: most objects die young. A request-scoped DTO lives for milliseconds; a cached config lives for hours. Collecting them the same way would be wasteful, so the heap is split:
- Young generation — where everything is born. Collected often (minor GC); most objects are already dead, so it's cheap.
- Old generation — survivors graduate here after enough young collections. Collected rarely (major/full GC); more expensive.
The collector's job, at a high level: find everything reachable from GC roots (thread stacks, statics), and reclaim the rest. "Stop-the-world" pauses are the price — the application threads freeze while the collector works. Modern collectors (G1, the default since Java 9; ZGC/Shenandoah for sub-millisecond pause targets) shrink those pauses by doing most work concurrently, but the fundamental tradeoff — throughput vs pause time — never disappears.
Watch it happen. Run any allocation-heavy program with GC logging and read the story:
public class GcStory {
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
byte[] garbage = new byte[10 * 1024 * 1024]; // 10MB of "request data"
System.out.println("allocated chunk " + i + ", len=" + garbage.length);
}
}
}
// Run: java -Xlog:gc*:file=gc.log GcStory
The log shows a repeating rhythm: allocation in Eden (young gen), a quick minor GC reclaims the dead chunks in milliseconds, and almost nothing ever reaches the old generation. That's the generational hypothesis working as designed — and it's why a healthy application's GC log is boring.
3. The one rule: diagnose before you tune
The most expensive GC mistake is tuning flags before measuring. The defaults (G1, ergonomically sized heap) are right for the overwhelming majority of applications. Tune only when a measured problem points at the collector:
- Frequent long pauses correlating with latency spikes → look at pause times in the GC log before touching flags.
- Heap keeps growing after full GCs → that's a leak (or an undersized heap), not a tuning problem — take a heap dump.
- Someone suggests
-XX:+UseParallelGC"for speed" → ask what the pause-time requirement is first. Throughput collectors trade longer pauses for total throughput; for a latency-sensitive API that's the wrong trade.
When you do need to look deeper — thread dumps, heap dumps, and JFR — that's the next post's territory: production diagnosis.
4. The interview one-liners
- Stack = per-thread locals and frames, freed on return, never GC'd. Heap = all objects, the GC's territory. Metaspace = class metadata; a leak there means a classloader leak.
- Generational GC bets most objects die young: cheap frequent minor collections in the young gen, rare expensive collections in the old gen.
- G1 is the default; ZGC/Shenandoah target sub-millisecond pauses. Throughput vs pause time is the eternal tradeoff.
- Read the
OutOfMemoryErrorsuffix — it names the neighborhood. And diagnose before you tune: the defaults are usually right.
Continue: Java Learning Roadmap 2026
Comments
Post a Comment