Java Memory Management and GC: Heap, Generations, G1, ZGC, and Real Leaks
Garbage collection is Javaโs most successful abstraction โ so successful that many developers ship for years without thinking about memory at all. Then one day the service OOMs at 2 a.m., or p99 latency spikes every forty seconds, and the abstraction demands its invoice.
You donโt need to be a GC engineer. You need the working model: where objects live, how collectors decide what dies, what the modern collectors trade against each other, and how leaks happen in a language that supposedly made them impossible. Thatโs this guide.
Stack and Heap: Where Things Actually Live
Every thread owns a stack โ small (defaults around 1 MB), fast, holding each method callโs frame: parameters, local variables, return address. Frames vanish automatically on return; the stack needs no GC. Overflow it with runaway recursion and you get StackOverflowError.
All objects live on the shared heap. Local variables of object type hold references pointing into it:
void handle() { int retries = 3; // primitive โ in the stack frame var user = new User("Priya"); // 'user' ref in frame โ User object in heap} // frame gone; the User? GC's problem nowAllocation in the heap is astonishingly cheap โ typically a pointer bump in a thread-local buffer, faster than Cโs malloc. Java programs allocate torrentially (every boxed int, every substring, every stream op) and are designed to; the entire GC architecture is built around making that torrent cheap to clean up.
What GC Actually Does: Reachability, Not Reference Counting
The JVM doesnโt count references (that would leak cycles and tax every assignment). Instead, collectors periodically trace reachability: start from the GC roots โ every threadโs stack variables, static fields, JNI handles โ and follow references. Everything reached is alive; everything else is, by definition, garbage:
roots: [thread stacks] [static fields] โ โ โผ โผ Order โโโถ Customer Config โ โผ โโโโโโโโโโ unreachable island LineItem โ A โ B โ โ collected despite the cycle โโโโโโโโโโTwo mental corrections this model makes:
- Cycles are harmless. Two objects referencing each other die together the moment nothing external reaches them.
- โUnusedโ isnโt collected โ unreachable is. An object youโll never touch again but thatโs still referenced from a static map is immortal. Hold that thought; itโs the entire leak story below.
System.gc() is a suggestion the JVM may ignore, and calling it in application code is a bug in almost all cases. Finalizers are deprecated and were never reliable; use try-with-resources for non-memory resources โ GC manages memory, nothing else.
The Generational Bet
Empirically, across nearly all programs: most objects die young (that substring, that boxed Integer, that streamโs intermediates โ dead microseconds after birth), while objects that survive a while tend to live long (caches, sessions, config). Collectors exploit this by splitting the heap:
โโโโโโโโโโโโโโโโโโ young generation โโโโโโโโโโโโโโฌโโโโ old generation โโโโโโ eden (new objects) โ survivor S0 โ survivor S1โ long-lived objects โโโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโดโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโ- Allocation happens in eden. When eden fills, a minor GC traces just the young generation โ and because most of it is dead, copying the few survivors out is fast. Dead objects cost nothing; theyโre simply never copied.
- Objects surviving several minor GCs get promoted to the old generation.
- When the old generation fills, a major/full GC works the whole heap โ historically the expensive, everything-pauses event.
Consequences you can use:
- Short-lived garbage is nearly free. Donโt contort code to avoid temporary objects on the JIT-optimized path; the generational design (plus escape analysis, which can avoid heap allocation entirely) already handles it.
- Premature promotion is the real cost. Medium-lived objects โ alive just long enough to reach the old generation, dead soon after โ are the GC-expensive pattern (think: per-request data held across slow calls).
- A โmemory leakโ graph in Java looks like a sawtooth whose valleys climb: each collection reclaims less, until OOM.
The Collectors That Matter in 2026
The JVM ships several collectors; you choose with a flag, and the differences are about pause time vs. throughput vs. memory overhead:
| Collector | Flag | Pauses | Best for |
|---|---|---|---|
| G1 (default) | -XX:+UseG1GC | ~10โ200 ms, tunable target | general-purpose services โ the sane default |
| ZGC | -XX:+UseZGC | sub-millisecond, heap-size-independent | latency-critical services, huge heaps |
| Parallel | -XX:+UseParallelGC | long, but max throughput | batch jobs where pauses donโt matter |
| Serial | -XX:+UseSerialGC | long | tiny containers, single-core |
G1 divides the heap into ~2048 equal regions (young and old are region sets, not contiguous blocks) and collects incrementally, targeting a pause goal (-XX:MaxGCPauseMillis=100) by choosing how many regions to clean per cycle โ โGarbage Firstโ means it picks the most-garbage regions for the best ROI per pause.
ZGC (and Shenandoah, its sibling in spirit) does almost everything concurrently with your running application using colored pointers and load barriers โ pauses stay under a millisecond even on terabyte heaps. Since JDK 21, generational ZGC added the young/old distinction, removing its main throughput penalty. The trend is clear: for latency-sensitive services on modern JDKs, ZGC has moved from exotic to routine.
Tuning advice that reflects actual practice: set the heap size (-Xmx/-Xms), pick the collector, and stop. The old dark art of tuning survivor ratios and tenuring thresholds is mostly obsolete on G1/ZGC; modern collectors self-tune better than folklore flags do. In containers, prefer -XX:MaxRAMPercentage=75 so the heap follows the container limit โ and remember the JVM needs off-heap room too (metaspace, thread stacks, buffers), so never give the heap 100% of the container.
Memory Leaks in a Garbage-Collected Language
Java leaks are always the same bug wearing different costumes: a reachable reference to something youโll never use again. The classic costumes:
1. The unbounded static cache โ the number-one offender:
static final Map<String, Result> CACHE = new HashMap<>(); // grows foreverStatic โ GC-root-reachable โ immortal. Fix: bound it (LRU via LinkedHashMap.removeEldestEntry, or a real cache like Caffeine with size/expiry), and ask whether it needed to be static at all.
2. Listeners and callbacks never unregistered. The publisherโs listener list keeps every subscriber โ and everything each subscriber references โ alive. Deregister on teardown, or the registry must use weak references.
3. Collections that only ever grow โ audit lists, โrecent itemsโ without a cap, maps keyed by session ID with no eviction on logout.
4. The mutated-key ghost from the HashMap guide: entries unreachable by lookup but still referenced by the map โ leaked and unfindable.
5. ThreadLocal in thread pools. Pool threads live forever, so per-request ThreadLocal values survive the request unless you remove() in a finally. This one produces the confusing cross-request-data-bleed bugs, too.
6. The subtle classics: a tiny subList/substring-style view pinning a huge backing structure (make an explicit copy when keeping a small slice of something big), and non-static inner classes silently holding their outer instance (this$0) โ an anonymous listener inside a giant object keeps the giant alive.
The diagnostic path when memory climbs: watch the sawtooth (jconsole/Grafana), then take a heap dump โ jmap -dump:live,file=heap.hprof <pid> or -XX:+HeapDumpOnOutOfMemoryError in prod JVM flags (do this today; the flag is free until the day it saves you) โ and open it in Eclipse MAT. The dominator tree points at the few objects retaining the most memory; the leak is nearly always in the top three, and itโs nearly always a collection.
Reading OutOfMemoryError Like a Pro
OutOfMemoryError is a family; the message names the culprit:
Java heap spaceโ the classic. Either the workload genuinely needs more (-Xmx) or you have a leak (heap dump, MAT, dominator tree).GC overhead limit exceededโ the JVM spent >98% of time collecting for <2% recovery: youโre at the top of the climbing sawtooth. Treat as a leak until proven otherwise.Metaspaceโ class metadata, not objects: runaway class generation (proxies, scripting) or classloader leaks on redeploy.unable to create native threadโ OS thread limit, not heap: thread-per-request without bounds (virtual threads largely retired this one).Direct buffer memoryโ off-heap NIO buffers, invisible to heap dumps; bounded by-XX:MaxDirectMemorySize.
One reflex worth installing: an OOMโs stack trace shows who made the final allocation โ usually an innocent bystander. The heap dump shows whoโs hoarding. Diagnose from the dump, not the trace.
What This Means for Everyday Code
Distilled into habits:
- Allocate freely in the small; think twice in the immortal. Temporaries are near-free; anything reachable from a static or a long-lived object needs a lifecycle plan.
- Every cache needs a bound and an eviction story โ โcacheโ without โevictionโ is โleakโ pronounced optimistically.
- Deregister what you register;
remove()yourThreadLocals infinally. - Immutable objects are GC-friendly as well as thread-friendly โ no old-gen object mutating to point at young objects (write barriers cost), no defensive lifetime puzzles.
- Ship with
-XX:+HeapDumpOnOutOfMemoryErrorand a metrics view of heap-after-GC. The trend line โ not the current usage โ is the health signal. - Reach for GC logs (
-Xlog:gc*) before GC flags. Measure, then tune; most โGC problemsโ are leaks or undersized heaps, not collector choice.
Frequently Asked Questions
Does setting variables to null help the GC? Almost never โ when a local variableโs method returns (or the JIT determines the variable is no longer used, which happens earlier), the reference stops counting toward reachability automatically. The exception that proves the rule: nulling out slots in long-lived arrays you manage yourself (the classic example is a hand-rolled stackโs popped slots, an actual bug in early collection implementations). In normal application code, x = null; lines are superstition.
What are weak, soft, and phantom references? Escape hatches from ordinary (โstrongโ) reachability. A WeakReference doesnโt keep its target alive โ the basis of WeakHashMap for caches-that-shouldnโt-own-their-keys. SoftReferences are cleared only under memory pressure (in practice, dedicated cache libraries with explicit sizing behave more predictably). Phantom references support post-mortem cleanup via Cleaner, the modern replacement for finalizers. Youโll use libraries built on these more often than youโll touch them directly โ but now the Javadoc mentions make sense.
Why does my JVMโs memory in top exceed -Xmx? Because -Xmx bounds only the Java heap. The process also holds metaspace, code cache (JIT output), thread stacks (1 MB ร threads), GC bookkeeping, and direct buffers. A 4 GB-heap JVM comfortably occupies 5+ GB of RSS. Container sizing that ignores this is the number-one cause of Kubernetes OOM-kills that โcanโt happen because Xmx is set.โ
Do virtual threads change GC behavior? They shift stack memory into the heap โ virtual thread stacks are heap objects that grow and shrink โ so massive-concurrency services see more heap traffic but no longer risk unable to create native thread. The concurrency guide covers the model; from the GCโs perspective theyโre just more (well-behaved, mostly young) objects.
Is finalize() really gone? Deprecated for removal, and you should behave as if removed: it ran at unpredictable times, possibly never, could resurrect objects, and slowed collection. Resource cleanup belongs to try-with-resources; last-resort safety nets to Cleaner.
A Five-Minute Lab You Can Run Today
Nothing builds intuition like watching a collection happen. Run any Java service or test locally with:
java -Xmx256m -Xlog:gc file.jar # small heap makes GC visible quicklyThen read the log lines: Pause Young (Normal) entries every so often, each reporting before->after(total) sizes and a pause of a few milliseconds โ thatโs the generational hypothesis working in real time (huge reclaim, tiny pause). Now write a deliberate leak โ a static ArrayList appended to in a loop โ and watch the pattern change: collections more frequent, after sizes climbing, Pause Full appearing, then the crash with heap dump if you enabled it. Ten minutes of this teaches more than any diagram, and the log vocabulary you just learned is exactly what youโll grep during a real incident.
Self-Check
- Two objects reference each other but nothing else references them. Collected or leaked โ and why would the answer differ in a reference-counting system?
- Why are minor GCs fast even when eden is full of garbage?
- A serviceโs heap-after-GC climbs 2% daily until weekly restarts โfixโ it. Name the three most likely culprits youโd check in the heap dump.
OutOfMemoryError: Java heap spaceโ the stack trace points atStringBuilder.appendin a logging utility. Is the logger the problem?- When would you pick ZGC over G1, and what did generational ZGC (JDK 21+) change about that decision?
(Answers: collected โ reachability tracing sees an unreachable island; refcounting would leak the cycle. Survivor-copying cost scales with live objects, and almost nothing in eden is live; the dead are never touched. Unbounded static cache, listener registry, ever-growing collection โ top of the MAT dominator tree. Probably not โ the trace shows the final straw; find the hoarder in the dump. Latency-sensitive or huge-heap services; generational ZGC removed most of the throughput penalty, making it viable as a general default.)
That completes the Java seriesโ core arc: from how the JVM runs your code to how it cleans up after it. The through-line, if there is one: Javaโs โceremonyโ โ static types, checked contracts, explicit design โ is the price of systems that stay debuggable at millions of lines and terabytes of heap. Pay it deliberately, and the platform repays you for decades.