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

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 type | What it means in practice |
|---|---|
| System class | Classes loaded by the bootstrap or system class loader, and therefore their static fields. |
| Thread | Every live thread object. |
| Java local / Java stack frame | Local variables and parameters in methods currently on a thread's stack. |
| JNI local, JNI global | References held by native code. |
| Busy monitor | Objects used as a lock by a thread that currently holds or waits on them. |
| Native stack | Objects referenced from native frames, such as during I/O. |
| Finalizable / unfinalized | Objects 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:
- Weak references do not prevent collection.
WeakHashMapuses weak keys; a common mistake is a value that refers back to its own key, which keeps the key strongly reachable and defeats the map. - Soft references are cleared at the collector's discretion in response to memory demand. A cache built on soft references can let the heap fill up before entries are cleared, which shows up as long, frequent collections rather than a clean failure.
- Phantom references and
Cleanerare for post-mortem cleanup of native resources, the mechanism behind direct buffers.
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:
- post-collection occupancy returning to a stable floor: no leak, maybe a heap that is too small for the peak;
- a floor that climbs steadily over hours, under steady traffic: a leak;
- a floor that climbs and then stabilises: a cache warming up, which may be fine.
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:
- 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.
- On a suspicious object, run Path to GC Roots, excluding weak and soft references.
- 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
- Eclipse Memory Analyzer: Garbage Collection Roots
- HotSpot Virtual Machine Garbage Collection Tuning Guide
- java.lang.ref package summary and ThreadLocal
- The jcmd command (GC.class_histogram)
MemoryTaking and reading a heap dump
Capture safely with jcmd, dump automatically on OOM, then read the dominator tree and paths to GC roots.
MemoryMetaspace and class loader leaks
Class metadata outside the heap: the flags, the jcmd views, and how redeploys leak class loaders.
MemoryDirect buffer memory and MaxDirectMemorySize
Why the error appears while the heap looks fine, what the default limit really is, and how to measure direct buffers.