Lesson 29 • Advanced
Memory Management & Garbage Collection
By the end you'll be able to picture where every value lives (stack vs heap), explain how Java's generational garbage collector reclaims objects for you, pick the right GC and heap flags, and hunt down the leaks the GC can't fix.
What You'll Learn in This Lesson
- ✓You'll be able to say what lives on the stack vs the heap, and why
- ✓You'll be able to trace an object's lifecycle from new to GC-eligible
- ✓You'll be able to explain generational GC: young/old, minor/major
- ✓You'll be able to choose between G1, ZGC, and Parallel collectors
- ✓You'll be able to use strong, soft, weak, and phantom references
- ✓You'll be able to find and fix common Java memory leaks, and tune -Xmx
Before You Start
This lesson assumes you're comfortable creating objects with new from Object-Oriented Programming, using lists and maps from Collections, and the idea of threads from Multithreading. Memory management touches all three — your data-structure choices, your thread count, and your object lifetimes all decide how much memory your program uses.
Real-World Analogy: A Desk and a Warehouse
💡 Analogy: Think of the stack as your desk. It's small and tidy. When you start a task you lay out exactly what you need on top; when the task is done you sweep it off — instantly, no thought required. Every worker (thread) has their own desk.
The heap is the warehouse out back. It's huge, shared by everyone, and holds the bulky items (your objects). Nobody clears their own warehouse junk, so a janitor — the garbage collector — walks the aisles, and anything no one is still holding a tag for gets hauled away. You never throw items out yourself; you just stop holding their tags, and the janitor does the rest.
A "tag" here is a reference — a variable pointing at an object. An object survives as long as something can still reach it through a chain of tags. Once the last tag is gone, the object is garbage and the janitor is free to reclaim its space.
1️⃣ Stack vs Heap — Where Values Live
When a method runs, Java gives it a stack frame — a slot that holds its primitive values (like an int) and its references (the tags that point at objects). When the method returns, that frame is popped off and its memory is reclaimed instantly. The actual objects those references point at live on the heap, which is shared by every thread and managed by the garbage collector.
So int count = 2; stores the 2 right on the stack, but User u = new User("Alice"); stores the object on the heap and only keeps the small reference u on the stack.
| Feature | Stack | Heap |
|---|---|---|
| Stores | Primitives, references, call frames | Objects, arrays |
| Lifetime | Until the method returns | Until no reference remains (then GC) |
| Scope | One per thread (private) | One, shared by all threads |
| Speed | Very fast (push/pop, LIFO) | Slower (allocation + GC managed) |
| Error when full | StackOverflowError | OutOfMemoryError |
User alias = alice; both names point at the same heap object — change it through one and you see it through the other.public class Main {
// A small class so we can watch objects being born and abandoned.
static class User {
String name; // a reference field, lives inside the object on the heap
User(String name) {
this.name = name;
System.out.println("Created on heap: " + name);
}
}
public static void main(String[] args) {
// 'count' is a primitive — its VALUE lives on the stack frame for main().
int count = 2;
System.out.println("count (stack primitive) = " + count);
// 'alice' is a reference on the stack pointing at a User OBJECT on the heap.
User alice = new User("Alice");
User bob = new User("Bob");
// Copy the REFERENCE, not the object. alias and alice point at the SAME object.
User alias = alice;
System.out.println("alias == alice ? " + (alias == alice)); // true: same object
// Dropping one reference does NOT free the object — alias still holds it.
alice = null;
System.out.println("alice = null, but alias keeps Alice alive");
// Now no reference points at Alice -> she is eligible for garbage collection.
alias = null;
bob = null;
System.out.println("alias/bob = null -> both objects now eligible for GC");
// GC runs automatically; you never call free()/delete() in Java.
System.gc(); // a HINT, not a command — the JVM decides when to actually collect
System.out.println("Asked the JVM to consider a GC cycle");
}
}count (stack primitive) = 2
Created on heap: Alice
Created on heap: Bob
alias == alice ? true
alice = null, but alias keeps Alice alive
alias/bob = null -> both objects now eligible for GC
Asked the JVM to consider a GC cycle2️⃣ The Object Lifecycle & Generational GC
Every object goes through the same arc: created with new → in use while something references it → unreachable once the last reference is dropped → collected when the GC reclaims its space. You only control the first three; the JVM owns the last.
The crucial observation that makes GC fast is the weak generational hypothesis: most objects die young. So the heap is split into generations:
- Young generation — where new objects are born. It has an Eden space (allocation happens here) plus two small survivor spaces. A minor GC cleans it: cheap and frequent, because nearly everything in it is already dead.
- Old generation (tenured) — objects that survive enough minor GCs get promoted here. A major / full GC cleans it: slower and rarer, because long-lived objects accumulate slowly.
Collecting the small young space often, and the big old space seldom, is far cheaper than scanning the whole heap every time. That single trick is why automatic memory management can keep up with millions of allocations per second.
You can watch this happen by adding -Xlog:gc when you launch. The worked example below floods the young generation with short-lived arrays so minor "Pause Young" collections fire repeatedly.
// Run your program with GC logging turned on to SEE collections happen:
// java -Xlog:gc -Xmx64m Main
//
// This program allocates lots of short-lived "garbage" so the young
// generation fills up and minor GCs fire. Most objects die young, which
// is exactly the assumption generational GC is built around.
public class Main {
public static void main(String[] args) {
long kept = 0;
for (int i = 0; i < 5_000_000; i++) {
byte[] block = new byte[256]; // born in Eden (young generation)
kept += block.length; // touch it so the JIT can't delete it
// 'block' goes out of scope each loop -> instantly becomes garbage
}
System.out.println("Allocated and discarded ~" + (kept / 1024 / 1024) + " MB of garbage");
}
}[0.012s][info][gc] Using G1
[0.103s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 24M->2M(64M) 1.842ms
[0.198s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 26M->2M(64M) 1.611ms
[0.291s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 26M->2M(64M) 1.503ms
Allocated and discarded ~1220 MB of garbageMain.java and run java -Xlog:gc -Xmx64m Main with a local JDK, or paste it into onecompiler.com/java (note: online compilers won't show the GC log).3️⃣ GC Algorithms — G1, ZGC, Parallel & Serial
The JVM ships several collectors, and you choose one with a launch flag. They trade off the same two things: throughput (how much real work gets done) versus pause time (how long the app freezes during a collection). You rarely need to switch from the default — but knowing the menu helps you tune later.
| Collector | Flag | Best for |
|---|---|---|
| Serial GC | -XX:+UseSerialGC | Single core, small heaps, embedded |
| Parallel GC | -XX:+UseParallelGC | Batch jobs — max throughput, pauses OK |
| G1 GC | -XX:+UseG1GC | Default since Java 9 — balanced, general server apps |
| ZGC / Shenandoah | -XX:+UseZGC | Latency-sensitive — sub-10ms pauses, big heaps |
G1 ("Garbage First") divides the heap into many small regions and collects the ones with the most garbage first, aiming for a pause-time goal you set with -XX:MaxGCPauseMillis. ZGC does almost all its work concurrently with your app, so pauses stay tiny even on multi-gigabyte heaps — at a small throughput cost. Parallel uses every core to collect as fast as possible but stops the world while it does, which is fine for batch work.
4️⃣ Reference Strengths — Strong, Soft, Weak, Phantom
Not all references are equal. Java lets you choose how tightly a variable holds an object, which tells the GC how eager it can be to collect it. Wrappers in java.lang.ref give you the weaker grips, which are the building blocks for leak-free caches.
| Reference | When the GC collects it | Typical use |
|---|---|---|
| Strong | Never, while it stays reachable | Ordinary variables (the default) |
| Soft | Only when memory is running low | SoftReference — memory-sensitive caches |
| Weak | At the next GC, once no strong ref remains | WeakReference, WeakHashMap keys |
| Phantom | After the object is finalized, for cleanup signalling | PhantomReference + a ReferenceQueue |
A soft reference is a great cache: the JVM keeps your cached values around until it actually needs the memory, then frees them rather than throwing OutOfMemoryError. A weak reference is more aggressive — it survives only until the next collection — which is exactly what WeakHashMap uses so entries vanish once their keys are gone. Phantom references never let you retrieve the object (get() always returns null); they exist only to tell you "this object has been collected, run your cleanup now".
import java.lang.ref.WeakReference;
import java.util.WeakHashMap;
import java.util.Map;
public class Main {
static class Image { // pretend this is a big, expensive object
final String name;
Image(String name) { this.name = name; }
}
public static void main(String[] args) {
// STRONG reference: the normal kind. Never collected while reachable.
Image logo = new Image("logo.png");
System.out.println("Strong ref holds: " + logo.name);
// WEAK reference: lets the GC reclaim the object as soon as no STRONG ref remains.
Image banner = new Image("banner.png");
WeakReference<Image> weak = new WeakReference<>(banner);
System.out.println("Weak ref before: " + (weak.get() != null ? weak.get().name : "collected"));
banner = null; // drop the only strong reference to the banner
System.gc(); // hint the GC to run
System.out.println("Weak ref after GC: " + (weak.get() != null ? weak.get().name : "collected"));
// WeakHashMap: entries disappear automatically when their KEY is no longer
// strongly reachable. Perfect for caches that must not cause leaks.
Map<Object, String> cache = new WeakHashMap<>();
Object key = new Object();
cache.put(key, "expensive result");
System.out.println("Cache size while key is held: " + cache.size());
key = null; // the only strong ref to the key is gone
System.gc();
System.out.println("WeakHashMap auto-evicts entries whose key was collected");
}
}Strong ref holds: logo.png
Weak ref before: banner.png
Weak ref after GC: collected
Cache size while key is held: 1
WeakHashMap auto-evicts entries whose key was collectedSystem.gc() depends on the JVM — a manual GC is only a hint and isn't guaranteed to run, so the exact output can differ between runs. Run it on onecompiler.com/java or a local JDK.5️⃣ Memory Leaks in Java — Yes, They Still Happen
A garbage collector frees unreachable objects. A leak is when you keep objects reachable by accident, so the GC is forbidden from collecting them and the heap slowly fills until you hit OutOfMemoryError. Three patterns cause the vast majority of real-world Java leaks:
- Static collections that only grow. A
static Listorstatic Maplives for the whole program. If you keep adding and never remove, every element stays reachable forever. Fix: bound the size, evict old entries, or hold the values as soft/weak references. - Listeners and callbacks you never unregister. Registering a listener stores a reference to your object inside the event source. If you forget to unregister it when you're done, the source keeps your object alive. Fix: always pair
addListenerwithremoveListener(often in afinallyor aclose()method). - ThreadLocal values on pooled threads. A
ThreadLocalattaches data to a thread. In a thread pool the threads never die, so that data lingers across tasks and leaks. Fix: always callthreadLocal.remove()in afinallyblock when the task ends.
The tell-tale sign of a leak is a heap that keeps climbing across full GCs and never comes back down. To confirm it, capture a heap dump (-XX:+HeapDumpOnOutOfMemoryError) and open it in a tool like VisualVM or Eclipse MAT to see which objects are pinning memory.
🎯 Your Turn #1 — Make an Object GC-Eligible
Fill in the four blanks so the object becomes eligible for garbage collection only after the last reference is dropped. The // 👉 hints tell you exactly what to write. Check your run against the expected output in the comments.
public class Main {
static class Session {
final String id;
Session(String id) {
this.id = id;
System.out.println("Opened session " + id);
}
}
public static void main(String[] args) {
// 🎯 YOUR TURN — fill in the blanks marked with ___
// 1) Create a Session with id "A" using 'new'
Session a = ___; // 👉 new Session("A")
// 2) Make 'copy' point at the SAME object as 'a' (copy the reference)
Session copy = ___; // 👉 just write: a
// 3) Drop ONE reference. The object is NOT collected yet — 'copy' still holds it.
a = null;
System.out.println("a = null, object still alive via copy");
// 4) Make the object eligible for GC by dropping the LAST reference
copy = ___; // 👉 set it to null
System.out.println("Session is now eligible for garbage collection");
// ✅ Expected output:
// Opened session A
// a = null, object still alive via copy
// Session is now eligible for garbage collection
}
}___ using the // 👉 hint, then run it on onecompiler.com/java or a local JDK and compare with the expected output in the comments.🎯 Your Turn #2 — Spot the Static-Collection Leak
This program demonstrates the #1 Java leak: a static list that only ever grows. Fill in the blanks to add blocks and report how many the list is still pinning in memory. The fix is in the final line — read it.
import java.util.ArrayList;
import java.util.List;
public class Main {
// ⚠️ A 'static' field lives for the whole program. If it only ever GROWS,
// every object you add stays reachable forever — a classic Java memory leak.
static List<byte[]> LEAK = new ArrayList<>();
static void cacheBadly() {
// 🎯 YOUR TURN — fill in the blanks marked with ___
// 1) Add a 1 KB block to the static list (this is the leak — nothing removes it)
LEAK.add(___); // 👉 new byte[1024]
}
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
cacheBadly();
}
// 2) Print how many blocks are still being held alive by the static list
System.out.println("Blocks the static list is keeping alive: " + LEAK.___()); // 👉 size
// ✅ Expected output:
// Blocks the static list is keeping alive: 3
// Fix: remove entries, use a bounded cache, or hold WeakReferences.
System.out.println("Fix: remove entries, use a bounded cache, or hold WeakReferences.");
}
}___ using the // 👉 hint, then run it on onecompiler.com/java or a local JDK and compare with the expected output in the comments.🏆 Mini-Challenge — Build a Self-Cleaning Cache
Time to fade the scaffolding. You get only a comment outline — no filled-in logic. Build a cache whose entries disappear automatically when their key is no longer referenced, using what you learned about WeakHashMap and weak references. The expected output is in the comments so you can self-check.
import java.util.HashMap;
import java.util.Map;
public class Main {
public static void main(String[] args) {
// 🎯 MINI-CHALLENGE: a self-cleaning cache
// 1. Create a Map<Object, String> that auto-evicts entries when the KEY
// is no longer strongly reachable (hint: WeakHashMap)
// 2. Put one entry in using a key you hold in a variable, then print size() (expect 1)
// 3. Set that key variable to null, call System.gc(), then print size() again
// 4. Explain in a println WHY the size can now drop to 0
//
// ✅ Expected (GC permitting):
// Size with key held: 1
// Size after key dropped: 0
// The entry left because its key had no strong references.
// your code here
}
}6️⃣ Tuning the Heap — -Xmx, -Xms & Friends
You size the heap and pick the collector at launch time, with JVM flags — not in Java code. The two you'll set most often are -Xms (the initial heap size) and -Xmx (the maximum heap size). If the heap needs more than -Xmx after a full GC, you get OutOfMemoryError: Java heap space.
Two rules of thumb cover most cases: keep -Xmx at roughly 75% of the machine's RAM (leave room for the OS, thread stacks, and off-heap memory), and in production set -Xms = -Xmx so the JVM grabs the full heap up front and never pauses to resize it.
# JVM memory tuning is done with LAUNCH FLAGS, not Java code.
# Typical production launch: fixed heap + G1 collector + heap dump on OOM
java -Xms512m -Xmx2g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+HeapDumpOnOutOfMemoryError \
-jar my-app.jar
# What the key flags mean:
# -Xms512m initial heap size (set = -Xmx in prod to avoid resize pauses)
# -Xmx2g MAX heap size (keep <= ~75% of the box's RAM)
# -XX:+UseG1GC G1 collector (default since Java 9); -XX:+UseZGC for low latency
# -XX:MaxGCPauseMillis=200 target pause time for G1, in milliseconds
# -Xss512k per-thread stack size (lower it when running many threads)
# See GC happen, then inspect a running JVM (get <pid> from 'jps'):
java -Xlog:gc*:file=gc.log:time -jar my-app.jar # write a full GC log
jcmd <pid> GC.heap_info # current heap usage
jmap -histo <pid> # histogram of live objects (leak hunting)Common Errors (and How to Fix Them)
- ❌
java.lang.OutOfMemoryError: Java heap space— the heap filled up. Either you genuinely need more memory (raise-Xmx) or, more often, you have a leak. Add-XX:+HeapDumpOnOutOfMemoryErrorand inspect the dump to find what's pinning objects. - ❌
java.lang.StackOverflowError— the stack, not the heap, overflowed. Almost always unbounded recursion (a method calling itself with no base case). Fix the recursion; only raise-Xssif the deep recursion is genuinely intentional. - ❌
OutOfMemoryError: GC overhead limit exceeded— the JVM is spending >98% of its time in GC and reclaiming almost nothing. A near-full heap with a slow leak. Profile and fix the leak; bumping-Xmxonly delays it. - ❌ Heap keeps growing across full GCs — the classic leak signature. Check your
staticcollections, unregistered listeners, andThreadLocals that never callremove(). - ❌
OutOfMemoryError: unable to create new native thread— not a heap problem at all. Each thread reserves stack space outside the heap; you've created too many. Use a bounded thread pool and lower-Xssif needed.
Pro Tips
💡 Set -Xms = -Xmx in production to avoid heap-resize pauses during traffic spikes.
💡 Always enable -XX:+HeapDumpOnOutOfMemoryError — when OOM hits at 3 AM you'll have the evidence to diagnose it.
💡 Don't call System.gc() in real code — it's only a hint and usually triggers a costly full GC at the worst moment.
💡 ZGC (Java 15+) holds pauses under ~10 ms even on multi-gigabyte heaps — reach for it when latency matters more than raw throughput.
📋 Quick Reference
| Concept | API / Flag | Use case |
|---|---|---|
| Initial / max heap | -Xms / -Xmx | Size the heap (set equal in prod) |
| Per-thread stack | -Xss | Shrink when running many threads |
| Pick a collector | -XX:+UseG1GC / +UseZGC | Balanced vs low-latency |
| See GC happen | -Xlog:gc | Log every collection |
| Dump on OOM | -XX:+HeapDumpOnOutOfMemoryError | Post-mortem leak analysis |
| Cache-friendly ref | WeakReference<T> / SoftReference<T> | Let the GC reclaim cached values |
| Self-evicting map | WeakHashMap | Entries drop when keys are gone |
| Inspect a live JVM | jcmd / jmap / VisualVM | Heap usage & object histograms |
Frequently Asked Questions
Do I ever need to free memory manually in Java?
No. Java has no free() or delete(). Once an object becomes unreachable (no live reference points to it), the garbage collector reclaims it automatically. Your only job is to stop referencing objects you no longer need — for example by letting variables go out of scope or setting long-lived fields to null.
What is the difference between the stack and the heap?
The stack stores method call frames, primitive values, and object references; it is small, per-thread, and freed automatically when a method returns. The heap is one large area shared by all threads where the actual objects and arrays live and where the garbage collector works. Running out of stack throws StackOverflowError; running out of heap throws OutOfMemoryError.
Why is Java's garbage collector 'generational'?
Because most objects die young. The GC splits the heap into a young generation (Eden plus survivor spaces) and an old generation. New objects start in young; cheap, frequent 'minor' GCs clear out the short-lived garbage there, and only the survivors are promoted to old, which is collected less often by slower 'major'/full GCs. Collecting the small young area frequently is far cheaper than scanning the whole heap.
Which garbage collector should I use — G1, ZGC, or Parallel?
Start with the default. G1 (default since Java 9) is the balanced choice for most server apps. Use Parallel GC when you only care about raw throughput in batch jobs and can tolerate longer pauses. Use ZGC (or Shenandoah) when you need very low, predictable pause times — usually under 10 ms even on large heaps — for latency-sensitive services.
If Java has a garbage collector, how can it still leak memory?
A 'leak' in Java means you are unintentionally keeping objects reachable so the GC cannot collect them. The usual culprits are static collections that only grow, listeners or callbacks you register but never unregister, and ThreadLocal values on pooled threads that are never removed. The objects are still referenced, so they are not garbage — and the heap fills up.
Should I call System.gc() to clean up memory?
Almost never. System.gc() is only a hint, and the JVM is free to ignore it. In practice it often triggers an expensive full GC pause at the worst possible moment and rarely helps. Let the JVM schedule collection itself; if memory is genuinely a problem, profile and tune heap size and the collector instead.
🎉 Lesson Complete!
Nicely done. You can now place any value on the stack or the heap, trace an object from new to GC-eligible, explain why generational GC (young/old, minor/major) makes automatic memory management fast, pick between G1, ZGC, and Parallel, reach for soft/weak references to build leak-free caches, recognise the three classic leak patterns, and size the heap with -Xms/-Xmx.
Next up: JVM Internals — classloading, JIT compilation, and how your bytecode actually executes.
Sign up for free to track which lessons you've completed and get learning reminders.