Memory Management

Reviewed & published by Brayan K

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.

Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

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.

FeatureStackHeap
StoresPrimitives, references, call framesObjects, arrays
LifetimeUntil the method returnsUntil no reference remains (then GC)
ScopeOne per thread (private)One, shared by all threads
SpeedVery fast (push/pop, LIFO)Slower (allocation + GC managed)
Error when fullStackOverflowErrorOutOfMemoryError
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");
    }
}

2️⃣ 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:

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.

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.

CollectorFlagBest for
Serial GC-XX:+UseSerialGCSingle core, small heaps, embedded
Parallel GC-XX:+UseParallelGCBatch jobs — max throughput, pauses OK
G1 GC-XX:+UseG1GCDefault since Java 9 — balanced, general server apps
ZGC / Shenandoah-XX:+UseZGCLatency-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.

ReferenceWhen the GC collects itTypical use
StrongNever, while it stays reachableOrdinary variables (the default)
SoftOnly when memory is running lowSoftReference — memory-sensitive caches
WeakAt the next GC, once no strong ref remainsWeakReference, WeakHashMap keys
PhantomAfter the object is finalized, for cleanup signallingPhantomReference + 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() is a REQUEST, not a command — the JVM may ignore it
        // entirely, and -XX:+DisableExplicitGC makes it a no-op. So ask, then
        // CHECK, rather than assuming one call was enough. This is how you
        // would write it in a test; assuming would make the next line a
        // coin flip.
        for (int i = 0; i < 20 && weak.get() != null; i++) {
            System.gc();
            try { Thread.sleep(10); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        }
        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");
    }
}

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:

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
    }
}

🎯 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.");
    }
}

🏆 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)

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

ConceptAPI / FlagUse case
Initial / max heap-Xms / -XmxSize the heap (set equal in prod)
Per-thread stack-XssShrink when running many threads
Pick a collector-XX:+UseG1GC / +UseZGCBalanced vs low-latency
See GC happen-Xlog:gcLog every collection
Dump on OOM-XX:+HeapDumpOnOutOfMemoryErrorPost-mortem leak analysis
Cache-friendly refWeakReference<T> / SoftReference<T>Let the GC reclaim cached values
Self-evicting mapWeakHashMapEntries drop when keys are gone
Inspect a live JVMjcmd / jmap / VisualVMHeap usage & object histograms

🎉 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.

Practice quiz

After User alias = alice; what does (alias == alice) print?

  • false
  • Compile error
  • true
  • null

Answer: true. Assigning a reference copies the tag, not the object, so both names point at the same heap object — true.

Where do objects created with 'new' live?

  • The heap
  • The stack
  • The PC register
  • The metaspace

Answer: The heap. Objects and arrays live on the heap, shared by all threads. The stack holds references and primitive locals.

When does an object become eligible for garbage collection?

  • When you call free()
  • When the method that created it starts
  • Immediately after 'new'
  • When the last reference to it is dropped

Answer: When the last reference to it is dropped. An object is eligible for GC once no live reference can reach it. Java has no free() or delete().

Why is Java's garbage collector called 'generational'?

  • It runs once per generation of the JVM
  • It splits the heap into young and old because most objects die young
  • It collects in alphabetical order
  • It only collects static fields

Answer: It splits the heap into young and old because most objects die young. The weak generational hypothesis says most objects die young, so young is collected cheaply and often, old rarely.

What does running out of stack space throw?

  • StackOverflowError
  • OutOfMemoryError
  • NullPointerException
  • GC overhead limit exceeded

Answer: StackOverflowError. Too-deep recursion overflows a thread's stack and throws StackOverflowError; the heap filling throws OutOfMemoryError.

Which collector has been the default since Java 9?

  • Serial GC
  • Parallel GC
  • G1 GC
  • ZGC

Answer: G1 GC. G1 (Garbage First) is the balanced default since Java 9; ZGC targets very low pauses, Parallel targets throughput.

When does a WeakReference's referent get collected?

  • Never while the JVM runs
  • At the next GC once no strong reference remains
  • Only on System.exit
  • Only when memory is critically low

Answer: At the next GC once no strong reference remains. A weak reference does not prevent collection — once no strong ref remains, the next GC can reclaim the object.

Which is a classic cause of a memory leak despite having a garbage collector?

  • Using local variables
  • Calling new too often
  • Using try-with-resources
  • A static collection that only ever grows

Answer: A static collection that only ever grows. A static collection that only grows keeps every element reachable forever, so the GC can never collect them.

What does System.gc() actually do?

  • Immediately frees all garbage
  • Is only a hint the JVM may ignore
  • Throws OutOfMemoryError
  • Clears the stack

Answer: Is only a hint the JVM may ignore. System.gc() is just a hint; the JVM decides when to collect. In practice it often triggers a costly full GC.

In production, why set -Xms equal to -Xmx?

  • To use less RAM
  • To disable the GC
  • So the JVM grabs the full heap up front and avoids resize pauses
  • To allow unlimited heap growth

Answer: So the JVM grabs the full heap up front and avoids resize pauses. Setting initial heap equal to max means the JVM allocates the whole heap immediately and never pauses to resize it.

Continue this course

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.

Related lessons