Technology  /  Java

โ˜• Java 15 guides ยท updated 2026

From the JVM to streams, generics, and concurrency โ€” the language behind most enterprise backends, taught with modern Java in mind.

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 now

Allocation 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:

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 โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Consequences you can use:


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:

CollectorFlagPausesBest for
G1 (default)-XX:+UseG1GC~10โ€“200 ms, tunable targetgeneral-purpose services โ€” the sane default
ZGC-XX:+UseZGCsub-millisecond, heap-size-independentlatency-critical services, huge heaps
Parallel-XX:+UseParallelGClong, but max throughputbatch jobs where pauses donโ€™t matter
Serial-XX:+UseSerialGClongtiny 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 forever

Static โ†’ 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:

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:

  1. 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.
  2. Every cache needs a bound and an eviction story โ€” โ€œcacheโ€ without โ€œevictionโ€ is โ€œleakโ€ pronounced optimistically.
  3. Deregister what you register; remove() your ThreadLocals in finally.
  4. 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.
  5. Ship with -XX:+HeapDumpOnOutOfMemoryError and a metrics view of heap-after-GC. The trend line โ€” not the current usage โ€” is the health signal.
  6. 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:

Terminal window
java -Xmx256m -Xlog:gc file.jar # small heap makes GC visible quickly

Then 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

  1. Two objects reference each other but nothing else references them. Collected or leaked โ€” and why would the answer differ in a reference-counting system?
  2. Why are minor GCs fast even when eden is full of garbage?
  3. 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.
  4. OutOfMemoryError: Java heap space โ€” the stack trace points at StringBuilder.append in a logging utility. Is the logger the problem?
  5. 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.