Java has garbage collection, not leak immunity: anything you keep reachable by accident is a leak.
Four reference strengths
| Type | Collected when… | get() after collection | Typical use |
|---|---|---|---|
Strong (Obj o = …) | Never, while reachable | — | Normal code |
Soft — SoftReference | Only when memory is low (before OOM) | null | Memory-sensitive caches |
Weak — WeakReference | At the next GC once only weakly reachable | null | WeakHashMap, canonicalising maps, listener registries |
Phantom — PhantomReference | After finalisation; enqueued for cleanup | always null | Post-mortem cleanup (Cleaner is built on it) |
import java.lang.ref.WeakReference;
public class Main {
public static void main(String[] args) {
StringBuilder data = new StringBuilder("payload");
WeakReference<StringBuilder> weak = new WeakReference<>(data);
System.out.println(weak.get()); // still strongly reachable via `data`
data = null; // drop the only strong reference
System.gc(); // a hint — usually honoured in practice
System.out.println(weak.get());
}
}payload
nullWeakHashMap
Keys are held weakly: when a key has no strong references elsewhere, its entry disappears on a later GC. Useful for attaching metadata to objects you don't own.
The usual leak suspects
| Leak | Why it's reachable | Fix |
|---|---|---|
| Static collection used as a cache | Static fields are GC roots — the map lives as long as the class | Bounded cache (LRU via LinkedHashMap.removeEldestEntry, Caffeine), or eviction |
| Listeners / callbacks never removed | The subject's listener list points at every subscriber | Unregister in a close(); or weak listeners |
| Non-static inner class / lambda captures | An inner instance holds Outer.this; lambdas hold captured variables | Use static nested classes; capture only what you need |
ThreadLocal in thread pools | Pool threads never die, so their thread-local values never go away | try { … } finally { tl.remove(); } |
| Unclosed resources | Streams, connections, native buffers hold memory and file handles | try-with-resources |
Wrong equals/hashCode as map key | Duplicate keys pile up; mutated keys become unreachable-yet-present | Immutable keys with a correct contract |
import java.util.ArrayList;
import java.util.List;
public class Main {
static final List<Runnable> LISTENERS = new ArrayList<>(); // static → a GC root
public static void main(String[] args) {
for (int i = 0; i < 1_000; i++) {
byte[] buffer = new byte[10_000];
LISTENERS.add(() -> System.out.print(buffer.length)); // lambda captures buffer
}
// ~10 MB can never be freed: every buffer is reachable through LISTENERS
System.out.println("listeners: " + LISTENERS.size());
LISTENERS.clear(); // unsubscribe → buffers become garbage
System.out.println("listeners: " + LISTENERS.size());
}
}listeners: 1000
listeners: 0public class Main {
private static final ThreadLocal<StringBuilder> BUFFER =
ThreadLocal.withInitial(StringBuilder::new);
static String handle(String request) {
try {
StringBuilder sb = BUFFER.get();
sb.setLength(0);
return sb.append("handled ").append(request).toString();
} finally {
BUFFER.remove(); // pooled threads live forever — always clean up
}
}
public static void main(String[] args) {
System.out.println(handle("GET /"));
}
}handled GET /Finding a leak
Confirm
Heap usage after each full GC keeps climbing (GC logs, JFR, VisualVM / JConsole graphs).
Dump the heap
jcmd <pid> GC.heap_dump heap.hprof(orjmap -dump:live,format=b,file=heap.hprof <pid>, or-XX:+HeapDumpOnOutOfMemoryError).Analyse
Open it in Eclipse MAT or VisualVM: look at the dominator tree and the path to GC roots of the biggest retained sets.
Fix & verify
Remove the unintended reference, then re-run and compare dumps.
$ jcmd # list running JVMs
$ jcmd <pid> GC.class_histogram | head # which classes use the most memory
$ jcmd <pid> GC.heap_dump /tmp/heap.hprof
$ jstat -gcutil <pid> 1000 # GC utilisation every secondHow can a garbage-collected language leak memory?
SoftReference vs WeakReference?
Why can a non-static inner class cause a leak?