Ch. 7 · Java

Java Garbage Collection and Retained Objects

How Java memory leaks happen despite garbage collection: GC roots, reachability, retained size, and finding the reference chain in a heap dump.

~7 min readadvancedupdated Oct 6, 2026

“Can Java have memory leaks if it has garbage collection?” is the classic opener. The follow-up is usually practical: “Heap usage grows until the service hits OutOfMemoryError after three days. How do you find the cause?” Interviewers want to see that you understand reachability, can name the common retention patterns, and know how to read a heap dump rather than just raising -Xmx.

This is an advanced topic because the fix depends on understanding object graphs, not on memorising GC algorithm names. Still, being able to describe generational collection briefly helps you explain why some leaks show up as a slowly rising old generation.

Before you start

You should know the difference between the stack and the heap, what an object reference is, and how HashMap and static fields work. It helps to have seen JVM flags like -Xmx and tools such as jcmd or VisualVM. Recall that Java objects are never freed explicitly; there is no delete.

The short answer

Yes. The garbage collector frees only objects that are unreachable from GC roots such as thread stacks, static fields and JNI references. A Java memory leak is an object that is still reachable but no longer needed, typically held by a static collection, an unbounded cache, a registered listener that is never removed, or a ThreadLocal on a pooled thread. To diagnose it, capture a heap dump, find the objects with the largest retained size, follow the path to the GC root, and remove or bound the reference that keeps them alive.

How it works

Every collector in HotSpot, from G1 (the default since Java 9) to ZGC and Parallel, works from the same idea: start at the roots and mark everything reachable. Anything not marked is garbage and its memory is reclaimed. Roots include:

  • local variables and operands on every live thread’s stack,
  • static fields of loaded classes,
  • JNI global references and objects used as monitors,
  • live Thread objects themselves.

Reachability is all that matters. Whether your code will ever use the object again is irrelevant to the collector.

public class SessionRegistry {
    private static final Map<String, Session> SESSIONS = new HashMap<>();

    public static void login(String id, Session s) {
        SESSIONS.put(id, s);           // reachable from a static root forever
    }
    // no logout removal: every session ever created stays in memory
}
java

Two size measures appear in heap analysis. Shallow size is the memory of the object itself, such as the 16 to 32 bytes of a HashMap.Node. Retained size is everything that would be freed if that object became unreachable. A small HashMap field can retain gigabytes. Heap analyzers compute retained size through the dominator tree: object A dominates B if every path from a root to B passes through A.

Generational collection explains the symptom. Most objects die young and are reclaimed cheaply in the young generation. Leaked objects survive several collections, get promoted to the old generation, and the old generation’s occupancy after each full or mixed collection keeps rising. That rising floor, not peak usage, is the signal of a leak.

Step-by-step walkthrough

Step 1: Confirm it is a leak, not a sizing problem

Enable GC logging and watch heap occupancy after collections:

java -Xlog:gc*:file=gc.log:time,uptime -Xmx2g -jar app.jar
Terminal

If occupancy after each old-generation collection climbs steadily under constant load, something is accumulating. If it is flat but high, the heap is simply too small for the workload.

Step 2: Capture a heap dump

jcmd <pid> GC.heap_dump /tmp/app.hprof
# or capture automatically at the moment of failure
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp -jar app.jar
Terminal

GC.heap_dump dumps only live objects by default, which removes noise. Two dumps taken an hour apart make growth easy to compare.

Step 3: Find what retains the most

Open the dump in Eclipse MAT or VisualVM. Look at the dominator tree or the leak suspects report, sorted by retained size. A typical finding: one ConcurrentHashMap retains 1.4 GB through 2 million CacheEntry objects. Class histograms (jcmd <pid> GC.class_histogram) give a quick, dump-free view of which types are multiplying.

Step 4: Follow the path to the GC root

Ask the tool for the shortest path to GC roots, excluding weak and soft references. The chain names the culprit, for example: Thread "http-nio-8080-exec-3" holds ThreadLocalMap, which holds RequestContext, which holds a 40 MB report buffer. The fix goes at the link you control.

Step 5: Remove or bound the reference

// unbounded: grows forever
private static final Map<String, Report> CACHE = new ConcurrentHashMap<>();

// bounded with eviction (Caffeine)
private static final Cache<String, Report> CACHE = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofMinutes(10))
        .build();
java

For ThreadLocal, always clear in finally. For listeners, unregister when the subscriber’s lifecycle ends.

Worked scenario

A Spring Boot API stores the current user’s request context in a ThreadLocal so deep service code can read it:

public final class RequestContext {
    private static final ThreadLocal<Context> CURRENT = new ThreadLocal<>();
    public static void set(Context c) { CURRENT.set(c); }
    public static Context get() { return CURRENT.get(); }
    public static void clear() { CURRENT.remove(); }
}
java

A filter calls set at the start of each request but never calls clear. Tomcat reuses its 200 worker threads, so each thread keeps the last request’s Context, including a cached response body. Memory looks stable at first, but a few huge export requests leave 200 large contexts pinned indefinitely. Worse, a later request on the same thread that skips the filter path reads the previous user’s context, which is a security bug as well as a leak.

The fix clears the value in a finally block so it cannot outlive the request:

@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
        throws ServletException, IOException {
    RequestContext.set(Context.from(req));
    try {
        chain.doFilter(req, res);
    } finally {
        RequestContext.clear();
    }
}
java

On Java 21 with virtual threads, consider ScopedValue (a preview API in 21) for the same purpose, since its binding ends automatically with the scope.

Common mistake

  • “Java cannot leak memory.” It cannot leak unreachable memory, but it happily retains reachable objects forever.
  • Calling System.gc() as a fix. It only requests a collection; reachable objects survive it regardless.
  • Raising -Xmx and moving on. A leak given more heap just takes longer to fail, and larger heaps can mean longer pauses.
  • Trusting WeakHashMap blindly. Its keys are weak, but if the value references the key, the entry never clears.
  • Confusing heap OOM with metaspace or native OOM. OutOfMemoryError: Metaspace points at class loader leaks, often from redeploying in an app server; unable to create native thread points at thread leaks.

Verify the behavior

This small program demonstrates that a reachable object survives collection while a cleared one does not, using a WeakReference as a probe:

import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;

public class Reachability {
    static final List<byte[]> RETAINED = new ArrayList<>();

    public static void main(String[] args) throws Exception {
        byte[] kept = new byte[10_000_000];
        RETAINED.add(kept);
        WeakReference<byte[]> keptProbe = new WeakReference<>(kept);
        WeakReference<byte[]> tempProbe = new WeakReference<>(new byte[10_000_000]);
        kept = null;                          // static list still holds it

        for (int i = 0; i < 5 && tempProbe.get() != null; i++) {
            System.gc();
            Thread.sleep(100);
        }
        System.out.println("retained alive: " + (keptProbe.get() != null)); // true
        System.out.println("temp alive: " + (tempProbe.get() != null));     // false (almost always)

        RETAINED.clear();
        for (int i = 0; i < 5 && keptProbe.get() != null; i++) {
            System.gc();
            Thread.sleep(100);
        }
        System.out.println("after clear: " + (keptProbe.get() != null));    // false (almost always)
    }
}
java

System.gc() is a hint, which is why the loop retries; on HotSpot with default settings it reliably triggers a full collection.

Follow-up questions

What are soft, weak and phantom references? Soft references are cleared when memory is tight, weak references at the next collection that finds them only weakly reachable, and phantom references are enqueued once the object is unreachable, never return it, and drive post-mortem cleanup through java.lang.ref.Cleaner.

Why are inner classes a leak risk? A non-static inner class or anonymous class holds an implicit reference to its enclosing instance, so registering it as a long-lived listener keeps the whole outer object alive.

What is the difference between a leak and high allocation rate? A high allocation rate causes frequent young collections and CPU overhead but a flat old-generation floor. A leak raises that floor over time.

Does String.substring leak? It did before Java 7 update 6, when substrings shared the original char[]. Modern JDKs copy the characters, so it no longer retains the larger string.

Interview exercise

A heap dump shows 3 GB retained by a static List<Listener> inside an event bus. Each Listener is an anonymous class created in a short-lived ReportView object. Explain the retention chain and two fixes.

Answer and reasoning

The chain runs from a static field (a GC root) to the list, to each anonymous listener, and through each listener’s implicit this$0 reference to its enclosing ReportView, which holds report data. Views are discarded by the UI, but because the bus still references their listeners, none of them are unreachable.

Fix one: give ReportView a lifecycle method such as close() that unregisters its listener, and call it reliably, ideally by making the view AutoCloseable. Fix two: have the event bus hold listeners weakly, for example in a WeakHashMap-backed set or with WeakReference wrappers, so registration does not extend lifetime. Fix two needs care, because a listener held only weakly can disappear while still wanted if nothing else references it. Explicit unregistration is usually clearer; weak references are a safety net.

Continue learning

Practise more JVM questions in the Java interview questions and the Java MCQs. Related notes: fixing OutOfMemoryError: Java heap space and try-with-resources and cleanup. For collector behaviour and tuning, read the HotSpot garbage collection tuning guide.

More in Java

read ✓Java · easy

Checked vs Unchecked Exceptions in Java

Checked exceptions must be caught or declared; unchecked ones needn't be. See the compiler rules, the lambda problem and when to use each.

~7 min readread →
esc