In Java, the garbage collector frees the objects that nothing references anymore. So a memory leak is an object that is no longer useful but still referenced: the GC cannot free it. Oracle’s guide puts it this way: the application keeps references to objects “unintentionally”.

Here are six classic leaks, each one reproduced by a small program of the public repository java-gc-lab, with what you see and the fix.

What keeps an object alive

The chain of references of a leak: a GC root, the static field AUDIT of the class StaticCollection, refers to an ArrayList, which refers to Order objects; they stay alive. A short-lived object that nothing refers to anymore is freed by the GC.What keeps an object aliveevery object reachable from a root stays aliveGC rootstatic AUDITArrayListthe list of the fieldOrderOrderOrderOrderobjectsuseless, but reachablestay alive: the leakshort-lived objectno reference left→ freed by the GC

The GC starts from “roots”: the static fields of the loaded classes, the local variables of the running threads… Every object that can be reached from a root stays alive. A leak is a path that someone forgot to cut.

CaseWhat keeps the objectFix
Static collectiona static fieldremove the elements, or do not keep them there
Cache with no limitthe map of the cachea maximum size or an expiry
Listenersthe list of listeners of the event busremove the listener
ThreadLocal in a poolthe pool thread, which never stopsremove() at the end of the task
Class loadersone object of the old versionrelease every reference to the old version
Resources not closednothing… but the file stays opentry-with-resources

How to see a leak

The first sign: the heap after each GC goes up from one GC to the next. It is explained with real logs in the post about OutOfMemoryError.

To know which object leaks, take two class histograms, a few seconds or minutes apart, on the running application:

jcmd <pid> GC.class_histogram | head

The class whose number of instances goes up is the leak. Be careful: this command runs a full GC first, so avoid it under heavy load on a sensitive production system. Each program of java-gc-lab leaks slowly (about 30 seconds) and prints its process id, to give you time to do it.

The six cases

1. A static collection

private static final List<Order> AUDIT = new ArrayList<>();
// on each request:
AUDIT.add(new Order());

A static field lives as long as its class, and so does the whole list. The histogram, after 5 s and then after 20 s:

  48:           368           5888  StaticCollection$Order
  19:          1584          25344  StaticCollection$Order

Fix: remove the elements when they are no longer needed, or do not keep them in a static collection.

2. A cache with no limit

private static final Map<String, Report> CACHE = new HashMap<>();
// a new key on each request: the cache only grows
CACHE.put(requestId, new Report());
  48:           378           6048  UnboundedCache$Report
  19:          1592          25472  UnboundedCache$Report

Fix: a maximum size. With the JDK alone, a LinkedHashMap that overrides removeEldestEntry removes the oldest entry when the size goes over a maximum; its javadoc gives it as an example for a cache.

3. Listeners never removed

Screen screen = new Screen();
BUS.register(screen);
// the screen is closed, but never removed from the bus
  48:           375           6000  Listeners$Screen
  19:          1592          25472  Listeners$Screen

The event bus lives for a long time, and it keeps every screen. Fix: remove the listener when the screen is closed.

4. A ThreadLocal in a thread pool

pool.submit(() -> {
    BUFFER.get().add(new Record());
    // no BUFFER.remove()
});
  48:           376           6016  ThreadLocalInPool$Record
  20:          1585          25360  ThreadLocalInPool$Record

According to the javadoc, each thread keeps its copy of a ThreadLocal as long as it is alive. The threads of a pool never stop: their buffers grow without end. Fix: BUFFER.remove() at the end of each task, in a finally.

5. Class loaders

ClassLoader loader = new ClassLoader(parent) { };
PLUGINS.add(Proxy.newProxyInstance(loader, interfaces, handler));

Each “reload” of a plugin creates a class loader and a new class. The list keeps one object of each version, so no old class loader can be freed. Here the heap grows little: the Metaspace grows.

jcmd <pid> VM.metaspace | grep "Total Usage"
Total Usage - 393 loaders, 3538 classes (1261 shared):
Total Usage - 1515 loaders, 4661 classes (1261 shared):

With a limit, it ends with OutOfMemoryError: Metaspace (see the post about OutOfMemoryError). Fix: release every reference to the old version (objects, classes, threads, listeners) before a reload.

6. Resources not closed

FileInputStream in = new FileInputStream(file);
in.read();
// no close()

This case is different. The stream is not kept anywhere, so it is not a heap leak: in my tests, when a GC runs, the JDK closes the files of the streams it frees (jcmd <pid> GC.run: from about 830 open files to about 90). But this program creates almost no garbage: no GC runs, and the open files pile up (lsof: about 400 after 5 s, 2,000 after 25 s). With a limit of 512 open files (in Docker):

java.io.FileNotFoundException: /tmp/java-gc-lab8035917117579278064.txt (Too many open files)

The heap is fine, and yet the application fails. Fix: try-with-resources. The javadoc of AutoCloseable says it: it ensures a prompt release and avoids running out of resources.

Avoiding leaks

  • In code reviews, look for static fields that hold collections, collections with no maximum size, register without unregister, ThreadLocal without remove(), streams opened outside a try-with-resources.
  • Set a limit on everything that grows: caches, queues, buffers.
  • Weak references: a WeakHashMap removes an entry when its key is no longer used elsewhere. A trap noted in the javadoc: if the value refers to its own key, the entry never goes away.
  • Watch the heap after GC in production: a slow leak shows over hours, not over one minute.

Tested with Java 8 (compiling, and the file case), 11, 17, 21 and 25 (Temurin 11.0.32, 17.0.20, 21.0.9, 25.0.4), on macOS; the file case also in Docker (eclipse-temurin:8 and :25).

Sources: Oracle’s troubleshooting guide on memory leaks, the documentation of jcmd, and the JDK 25 javadoc of ThreadLocal, LinkedHashMap, WeakHashMap and AutoCloseable.

Further reading