Memory And GC
You’ve written eight modules of real Java — objects, collections, streams, threads. Every one of those objects went somewhere, and something quietly cleaned up after you the entire time. This module is where you finally meet that something: the heap, the garbage collector, and the one bug class that takes down more production Java systems than any other — the memory leak.
The Goal
By the end of this module you can:
- Draw the full memory picture — stack frames with locals and references, heap with objects — and narrate it out loud
- Trace an object’s lifecycle from
newto garbage collected, and say exactly when it becomes collectible - Explain generational GC — young gen, old gen, minor vs major — and why generations exist in one sentence
- Define a Java memory leak precisely (reachable-but-useless) and name three real-world shapes it takes
- Watch your own program’s heap live in jconsole, recognise a healthy sawtooth, and spot a leak by its silhouette
The Lesson
The full memory picture
Back in Module 01 you got a sneak preview: frames on the stack, objects on the heap, arrows between them. Now that you’ve written real code, the picture pays off properly.
Every running JVM splits its memory into two main regions:
| Region | What lives there | Lifetime |
|---|---|---|
| The stack | One frame per method call. Each frame holds that method’s local variables — primitives directly (int amount = 500), and references (arrows) for everything else | Frame dies the instant the method returns. Automatic, instant, free |
| The heap | Every object you ever create with new — and strings, arrays, collections, all of it | Object lives until the garbage collector proves nobody points at it anymore |
There’s also metaspace — where the JVM stores class definitions themselves (the blueprint for Expense, not the expense objects). One line is all you need for now; it comes back in the OutOfMemoryError section.
Here’s the picture for a real moment in a program — main created a ledger and is inside a method adding an expense:
flowchart LR
subgraph S[The Stack]
M[main frame holds ref ledger]
A[addExpense frame holds ref e and int idx]
end
subgraph H[The Heap]
L[Ledger object with internal list]
E[Expense object amount 4500]
T[String object Goa dinner]
end
M --> L
A --> E
L --> E
E --> T
Read it aloud: “The main frame holds a reference to the Ledger object on the heap. The addExpense frame holds a reference to a new Expense. The Ledger also points at that Expense, and the Expense points at its description String.” Notice the Expense has two arrows into it. That detail is about to matter a lot.
The rule that drives everything in this module: stack memory frees itself (frames pop when methods return), but heap memory must be reclaimed by the garbage collector — and the GC’s only question is “can anything still reach this object?”
The object lifecycle
Every heap object lives the same four-act life:
flowchart TD
A[Created with new on the heap] --> B[Referenced reachable from a stack frame or another live object]
B --> C[Last reference dropped or goes out of scope]
C --> D[Unreachable no path from any live frame]
D --> E[Garbage collector frees the memory at some later point]
The crucial word is reachable. The GC starts from the “roots” — every local variable in every live stack frame, plus static fields — and walks every arrow. Anything it can reach, by any path, survives. Anything it can’t reach is garbage, no matter how big or recently used.
Two consequences worth burning in:
- You never free memory in Java. There is no
delete. You make objects unreachable (let variables go out of scope, remove them from collections, null them out) and the GC does the rest. Coming from JS, this is familiar — V8 does the same for your JavaScript objects. - Unreachable doesn’t mean instantly freed. The GC runs when it decides to. An unreachable object might sit dead on the heap for a while. That’s fine — it’s collectible, which is all that matters.
In the diagram above, when addExpense returns, its frame pops — but the Expense object survives, because the Ledger still points at it. One live arrow is enough. That’s not a bug; that’s the whole design. The bug version comes later.
Generational GC — why “most objects die young” changed everything
The JVM could scan the entire heap every time it collects. Early JVMs did, and they froze the whole program to do it. Then engineers noticed a statistical fact that holds across almost all real programs:
Most objects die young. The temporary String in a loop, the stream pipeline’s intermediate objects, the request object handled and discarded — the vast majority of objects become garbage within moments of being created. A small minority (your Ledger, your caches, your long-lived services) survive for ages.
So the heap is split into generations:
| Generation | Who lives there | Collected by |
|---|---|---|
| Young gen | Newly created objects | Minor GC — runs often, scans only this small region, very fast |
| Old gen | Objects that survived several minor GCs and got promoted | Major GC — runs rarely, scans the big region, expensive |
flowchart LR
N[new object] --> Y[Young gen]
Y -->|dies young| G[Freed cheaply by minor GC]
Y -->|survives several minor GCs| O[Old gen promoted]
O -->|eventually unreachable| M[Freed by major GC]
Why this is brilliant: a minor GC scans a small region where most things are already dead — so it’s cheap and it reclaims a lot. The expensive major GC only runs when the old gen actually fills up. The generational bet pays off precisely because the “most objects die young” statistic is true.
That’s all the GC theory you need right now. No tuning flags, no collector names — modern JVMs (G1 is the default) handle the details well. What you need is the model, because it explains everything you’re about to see in jconsole.
What a Java memory leak actually is
Here’s the part that surprises people: Java has a garbage collector, and Java programs leak memory constantly in production. How?
A Java memory leak is an object that is reachable but useless. The GC can still trace a path to it, so it can never be collected — but your program will never actually use it again.
The GC is not psychic. It answers “can anything reach this?” — not “will anyone ever use this?” Keep a live reference to something useless and the GC’s hands are tied, forever.
The three classic shapes, all of which you will meet in real codebases:
| The shape | What it looks like | Why it leaks |
|---|---|---|
| The static list that grows forever | static final List<Order> ALL_ORDERS = new ArrayList<>(); and every order ever gets added, never removed | Static fields are GC roots — they live as long as the program. Everything in that list is reachable until the JVM dies |
| The cache without eviction | A HashMap used as a cache — entries go in on every request, nothing ever takes them out | Each entry seemed small. Multiply by months of uptime. The map holds a reference to every value forever |
| The listener never removed | You register an object as a listener or callback, then forget about the object — but the event source still holds a reference to it | You think the object is gone; the framework’s listener list disagrees. Reachable, useless, immortal |
Spot the pattern: every leak is a long-lived thing holding references to short-lived things. The container outlives its contents’ usefulness. When you hunt a leak, you hunt the long-lived collection.
OutOfMemoryError — reading the crash
When leaked objects pile up until the heap genuinely cannot fit one more allocation — even after the GC has tried its hardest — the JVM throws this:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at LeakDemo.main(LeakDemo.java:12)
Read it like any stack trace (Module 01 skill), but with one twist — the message after the colon tells you which memory region ran out:
| Message | Meaning | Usual cause |
|---|---|---|
Java heap space | The heap is full of live, reachable objects | A leak, or the heap is genuinely sized too small for the workload |
Metaspace | The class-definition area filled up | Something keeps loading new classes endlessly — a frameworks-gone-wrong problem, rare in your own code |
GC overhead limit exceeded | The GC is running constantly and reclaiming almost nothing | The heap is nearly all live objects — a leak in its death throes |
One trap: the at line in an OOM trace shows where the final straw allocation happened — which is often innocent code. The leak built up elsewhere over hours. The trace tells you the program died of overcrowding; it doesn’t tell you who’s hoarding. For that, you need to look at the heap.
Seeing it — jconsole and the sawtooth
The JDK you already installed ships a free GUI tool for exactly this. With any Java program running, open a second terminal and type:
jconsole
A window opens listing running Java processes. Pick yours, accept the insecure-connection prompt (fine locally), and click the Memory tab. You’re now watching the heap live.
A healthy program draws a sawtooth: heap usage climbs as the program allocates objects, then a GC runs and the line drops sharply, then climbs again, drops again. Up-drop, up-drop, like saw teeth. Each drop is the GC reclaiming dead young-gen objects — the generational model from earlier, drawn in real time.
The two silhouettes to memorise:
| Shape on the chart | What it means |
|---|---|
| Sawtooth — climbs, drops back to roughly the same floor each time | Healthy. Objects are dying young and getting collected. The floor is your live data |
| Rising staircase — climbs, GC drops it a little, but the floor keeps rising | Leak. Each GC reclaims less because more of the heap is reachable-but-useless. The floor will eventually hit the ceiling: OutOfMemoryError |
The floor after each GC drop is the number that matters: that’s your live set — what the GC couldn’t collect. Healthy programs have a flat floor. Leaking programs have a floor that only goes up. You’re about to see both with your own eyes.
Why this matters in a real job
A production memory incident is a rite of passage for every backend engineer: the service that’s fine at 10 AM, slow at 4 PM, and dead at midnight — restarted by a cron job someone added years ago because nobody found the leak. The engineer who can say “the heap floor is rising after each GC, so we have a leak; let’s find the long-lived collection” is suddenly the most valuable person in the incident channel.
And in interviews: JVM memory questions appear in every single Java interview. Stack vs heap, what makes an object collectible, why generations exist, what a leak is despite GC — these are the exact questions, and after this module you answer them from experience, not memorisation.
Build This
You’re building LeakDemo — a program that leaks on purpose — then watching it leak, crashing it, and fixing it.
- Create a folder
leakdemo, and inside itLeakDemo.java:
import java.util.ArrayList;
import java.util.List;
public class LeakDemo {
// A GC root. Everything added here is reachable forever.
private static final List<byte[]> CACHE = new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
System.out.println("Leaking 1 MB every half second. Attach jconsole now.");
int round = 0;
while (true) {
CACHE.add(new byte[1024 * 1024]); // 1 MB that can never be collected
round++;
System.out.println("Round " + round + " - holding " + round + " MB");
Thread.sleep(500);
}
}
}
- Compile and run it:
javac LeakDemo.java
java LeakDemo
-
Open a second terminal and run
jconsole. Connect to the LeakDemo process, open the Memory tab. Watch for a minute: heap climbs and climbs. Click the Perform GC button — notice the line barely drops. The GC ran; it just couldn’t collect anything, because every byte array is still reachable throughCACHE. This is the rising staircase. Kill the program with Ctrl+C before your laptop gets grumpy. -
Now crash it on purpose, in a small sandbox. Run it with a 32 MB heap ceiling:
java -Xmx32m LeakDemo
Within about 15 rounds you’ll get java.lang.OutOfMemoryError: Java heap space. Read the full error: find the region name in the message, find the at line. Notice the at line points at the CACHE.add allocation — the final straw — which in this demo is also the culprit. In real systems it usually isn’t; remember that.
- Fix the leak. Change the loop body so the byte array is a local variable instead of being added to the static list:
byte[] scratch = new byte[1024 * 1024]; // local - unreachable as soon as the loop repeats
(Delete or comment out the CACHE.add line.) Recompile, run with java LeakDemo, attach jconsole again.
-
Watch the Memory tab now: a clean sawtooth. The heap climbs as the loop allocates, a minor GC fires, the line drops back to a flat floor — over and over. Same allocation rate as before; the only difference is reachability. Say it out loud: “the objects die young now, so minor GC reclaims them.” That sentence is the whole module.
-
Bonus rep: instead of deleting the cache, bound it — after the
add, writeif (CACHE.size() > 20) CACHE.remove(0);. Run with-Xmx64mand watch the heap stabilise. You just wrote eviction — the difference between a cache and a leak.
Where to Practice
| Resource | What to do there | How long |
|---|---|---|
| dev.java | Read the garbage collection section of the JVM tutorials — it covers generations with diagrams | 25 min |
| Baeldung | Search “java memory leaks” and read the article — match each leak type to the three shapes from this module | 25 min |
| Baeldung | Search “jvm garbage collectors” and skim — just learn the names exist, no tuning | 15 min |
| HackerRank Java | Do any two exercises from earlier modules, but run jconsole alongside and watch the heap while they run | 20 min |
How to Practice
- Watch, don’t memorise. One hour staring at jconsole while real programs run teaches more than ten articles.
- Predict before you look. Before attaching jconsole, say out loud what shape you expect. Being wrong is the lesson.
- Reread your own old code from modules 04 and 07 and ask of every collection: who removes things from this, and when? If the answer is “nobody,” you’ve found a potential leak shape.
- Don’t touch tuning flags beyond
-Xmxfor experiments. Flag-tweaking without understanding is cargo culting.
Check Yourself
- What lives on the stack, what lives on the heap, and which one does the GC manage?
- What is the GC’s one and only question when deciding whether an object can be freed?
- Why does splitting the heap into young and old generations make GC faster? What statistical fact is it betting on?
- What’s the difference between a minor GC and a major GC?
- Java has a garbage collector. Define a Java memory leak anyway.
- Name the three classic leak shapes and the pattern they share.
- You see
OutOfMemoryError: Java heap spacewith anatline pointing to harmless code. Why might theatline not be the culprit? - On a jconsole heap chart, what’s the difference between a healthy program and a leaking one?
Answers
- Stack: one frame per method call, holding local primitives and references — frees itself when methods return. Heap: every object created with
new. The GC manages the heap only. - “Can anything still reach this object?” — starting from stack frames and static fields, following references. Reachable survives; unreachable is garbage.
- Most objects die young, so a frequent fast scan of just the small young gen reclaims most garbage cheaply. The full expensive scan is saved for the rare times old gen fills.
- Minor GC collects only the young gen — frequent and fast. Major GC collects the old gen — rare and expensive. Objects that survive several minor GCs get promoted to old gen.
- An object that is reachable but useless — the program holds a live reference to it (so GC can’t collect it) but will never actually use it again.
- The static list that grows forever, the cache without eviction, the listener never removed. Shared pattern: a long-lived thing holding references to things that stopped being useful.
- The
atline shows where the final failed allocation happened — the last straw. The leak accumulated elsewhere, possibly over hours, in some long-lived collection. - Healthy: a sawtooth whose floor stays flat after each GC drop. Leaking: a rising staircase — each GC reclaims less and the floor climbs until it hits the heap ceiling.
Explain it out loud: Record a voice note explaining to a JS developer friend why a language with a garbage collector can still leak memory — use the static list example, walk through reachability, and describe what their jconsole chart would look like. If you can’t explain “reachable but useless” without notes, reread that section.
Still Unclear?
Copy-paste any of these into Claude — they deepen understanding without doing the work for you:
I understand Java GC frees unreachable objects. Walk me through reachability
from GC roots step by step using this code: [paste your LeakDemo]. Draw the
reference graph in text, then ask me which objects are collectible at three
different moments in the program and check my answers.
Explain the generational hypothesis (most objects die young) using a UPI
payment backend as the example: which objects in a payment request die young,
and which survive to old gen? Then quiz me on minor vs major GC.
Here is a jconsole heap pattern I observed: [describe the shape you saw].
Help me interpret it. Then describe three other heap chart shapes I might see
in production and make me diagnose each one before revealing the answer.
Why AI Can’t Do This For You
AI can define a memory leak perfectly. But at 11 PM when the service is dying, someone has to attach a tool to a live JVM, read the heap’s shape, and know whether that floor is rising — AI has no eyes on your jconsole and no memory of what “normal” looks like for your system.
And the interviewer asking “why do generations exist?” can tell in five seconds whether you’ve watched a sawtooth with your own eyes or memorised a paragraph. Today you earned the real answer.
Module done? Add it to today’s tracker