Memory

GC roots and Java memory leaks: why unreachable is the only thing that gets freed

Memory · Published 2026-10-21

A thick tree root running across bare, pebbly soil at the foot of a trunk
Everything the collector keeps is reachable from a root. Photo: Image Catalog, Flickr, CC0.

Java has no free(), so a Java memory leak is never "memory that was not released". It is memory that is still reachable: some chain of references runs from a GC root to objects the program no longer needs. Finding a leak means finding that chain.

Reachability in one paragraph

All HotSpot collectors work from the same principle. They start from a set of roots, follow every reference they find, and treat everything they reach as live. Anything not reached is garbage and its space can be reused, whatever its age or size. A collector never asks whether an object will be used again; it cannot know. If your code holds a reference, the object stays.

That has two useful consequences. First, cycles are not a problem: two objects that refer only to each other are collected together once nothing outside points to them. Second, every leak has a concrete, inspectable cause, a path of references that a heap dump can show you.

What counts as a GC root

The exact internal list depends on the JVM, but the categories that analysis tools report are stable. Eclipse Memory Analyzer, for instance, documents these root types in its GC roots help page:

Root typeWhat it means in practice
System classClasses loaded by the bootstrap or system class loader, and therefore their static fields.
ThreadEvery live thread object.
Java local / Java stack frameLocal variables and parameters in methods currently on a thread's stack.
JNI local, JNI globalReferences held by native code.
Busy monitorObjects used as a lock by a thread that currently holds or waits on them.
Native stackObjects referenced from native frames, such as during I/O.
Finalizable / unfinalizedObjects waiting in, or registered for, the finalizer queue.

Most real leaks end at the first three: a static field, a long-lived thread, or a frame of a thread that never returns.

The usual accidental references

Static collections that only grow

A static Map used as a cache, a registry or a "seen IDs" set is the textbook leak. Each entry is reachable from a system class forever. Bound such structures (a maximum size with eviction), or use a cache library that does it for you.

// grows for the life of the process: every key ever seen stays reachable
private static final Map<String, Session> SESSIONS = new ConcurrentHashMap<>();

ThreadLocal in pooled threads

A value stored in a ThreadLocal lives as long as the thread, and threads in a pool live as long as the pool. The ThreadLocal javadoc notes that a thread holds an implicit reference to its copy as long as the thread is alive and the ThreadLocal instance is accessible. Always clear in a finally block:

CONTEXT.set(ctx);
try {
    handle(request);
} finally {
    CONTEXT.remove();
}

Listeners and callbacks never unregistered

Registering an object with a long-lived publisher (an event bus, a scheduler, a JMX notification emitter) makes it reachable from that publisher. If the subscriber's lifecycle ends but nobody unsubscribes, it stays, and everything it references stays with it.

Inner classes and lambdas capturing their outer object

A non-static inner class instance holds a reference to its enclosing instance. A lambda captures what it uses, including this if it touches an instance field. Handing such an object to a long-lived component keeps the outer object alive.

Class loader leaks

In containers that redeploy applications, one stray reference from a system-level object into the application (a JDBC driver registered with DriverManager, a thread started by the application and never stopped, a ThreadLocal value) keeps the application's class loader reachable, and with it every class it loaded. The symptom is Metaspace growth after each redeploy; see the Metaspace guide.

Unbounded queues

A producer faster than its consumer fills an unbounded queue with live tasks. This is not a leak in the strict sense, but it looks exactly like one in memory graphs. The thread dump of the consumer usually tells you why it is slow.

Soft, weak and phantom references

The java.lang.ref package lets code refer to an object without keeping it strongly reachable:

When you look for leaks in a tool, exclude weak and soft references from the paths you inspect: they are not what keeps an object alive.

Is it really a leak?

Before opening a heap dump, confirm the pattern with the GC log (see reading GC logs). Look at heap occupancy after each collection, not the peaks:

A cheap first look without a full dump is the class histogram, which counts instances and bytes per class:

$ jcmd <pid> GC.class_histogram | head -25

Take two histograms some time apart and compare. A class whose instance count grows in proportion to uptime is your suspect. By default the histogram covers live objects only, and the jcmd reference documents an -all option to include unreachable ones.

Tracing a suspect back to its root

With a heap dump open in a tool such as Eclipse Memory Analyzer, the workflow is always the same:

  1. Find the biggest retainers in the dominator tree: an object dominates another if every path from the roots to the second passes through the first. Its retained size is what would be freed if it disappeared.
  2. On a suspicious object, run Path to GC Roots, excluding weak and soft references.
  3. Read the path from the root downwards. The first link that should not exist (a static map holding sessions, a thread-local holding a request) is the bug.

The heap dump guide covers capturing the dump safely and using these views step by step.

Sources