How do memory leaks happen in Java, and what are the most common causes?
A Java memory leak is memory that the garbage collector cannot reclaim, because it is still reachable. The collector is doing its job correctly; the problem is that something in the program still holds a reference to an object nobody wants any more.
The causes, in rough order of how often you will meet them. Unbounded collections. A cache or a list that grows forever and is never trimmed. This is the most common cause by a wide margin, and the fix is an eviction policy rather than a bigger heap. Listeners that are never removed. Registering a listener on a long-lived object and never deregistering it keeps the listener, and everything it references, alive. Static collections holding objects that should have died. Thread locals that are never removed, particularly in a thread pool where the thread outlives the request. And class loader leaks, where a class loader stays reachable because something it loaded is still reachable, which in turn keeps all of that loader's classes in Metaspace alive.
The pattern worth naming is the retained size versus the shallow size. A single small object can retain a huge object graph if it holds a reference to a list of a million entries. That is why a leak often looks like a small number of objects in a heap dump but a large amount of memory.
The other thing to understand is that a leak in Java usually shows up as a slow growth over hours or days rather than an immediate failure. The application gets slower as the collector works harder, and eventually it fails with an OutOfMemoryError — often in a completely unrelated place, because the memory ran out when something else tried to allocate.
Here is the same thing as runnable code:
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
Runtime runtime = Runtime.getRuntime();
// A static collection that keeps growing: a classic leak.
List<String> cache = new ArrayList<>();
for (int i = 0; i < 100_000; i++) {
cache.add("entry " + i);
}
System.out.println("cache size : " + cache.size());
System.out.println("used heap MB : " + usedMb(runtime));
// Clearing the reference lets the collector reclaim it.
cache.clear();
System.out.println("after clear : used heap: " + usedMb(runtime) + " MB");
System.out.println("the collector decides when to run");
}
static long usedMb(Runtime runtime) {
return (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024);
}
}