The full picture
- Confirm: GC logs or metrics — if full GCs reclaim less each time, it's a leak; if memory returns after GC, it's just churn or an undersized heap.
- Capture: heap dump at low load + at high water mark. Always run with
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=…in prod — the dump after the crash is free evidence. - Analyze: in MAT open the dominator tree and 'Leak Suspects' — you're looking for the retained size champion and the reference path to GC roots that keeps it alive.
- Fix pattern, not instance: bound the cache (Caffeine with maximumSize/expiry), deregister listeners (try-with-resources / weak listeners),
remove()ThreadLocals in pooled threads, isolate classloaders.
// 1. The static map that only grows
static final Map<UserId, Session> CACHE = new HashMap<>(); // no eviction = leak
// 2. ThreadLocal in an app running on a shared pool
static final ThreadLocal<BigBuffer> BUF = new ThreadLocal<>();
// pool threads never die -> value never GC'd. Always BUF.remove() in finally.
// 3. Mutated HashMap key
map.put(order, x); order.setId(99); // bucket now wrong -> unreachable entry