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 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.
| Case | What keeps the object | Fix |
|---|---|---|
| Static collection | a static field | remove the elements, or do not keep them there |
| Cache with no limit | the map of the cache | a maximum size or an expiry |
| Listeners | the list of listeners of the event bus | remove the listener |
ThreadLocal in a pool | the pool thread, which never stops | remove() at the end of the task |
| Class loaders | one object of the old version | release every reference to the old version |
| Resources not closed | nothing… but the file stays open | try-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
staticfields that hold collections, collections with no maximum size,registerwithoutunregister,ThreadLocalwithoutremove(), streams opened outside atry-with-resources. - Set a limit on everything that grows: caches, queues, buffers.
- Weak references: a
WeakHashMapremoves 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.