Java can't leak memory the way C can — but it leaks it a different way: objects that are technically reachable, and therefore un-collectable, long after they should have been forgotten.
Published September 23, 2026
"Java has a garbage collector, so it can't leak memory" is a common misconception. The GC only reclaims objects that are unreachable. A Java memory leak is always the same underlying shape: something keeps an object reachable — usually accidentally — long after the application has logically finished with it.
class SessionCache {
private static final Map<String, Session> cache = new HashMap<>(); // static — a permanent GC root
static void put(String id, Session session) { cache.put(id, session); }
// no corresponding remove() ever called anywhere
}
A static field is a GC root (see Garbage Collection Fundamentals) — it's reachable for as long as the class stays loaded, which for most application classes means for the entire lifetime of the JVM. Every Session ever put into this map stays reachable forever, even after the actual user session has ended, because nothing ever calls a corresponding remove(). This is leak source #1 specifically because it's so easy to write innocently — a cache that grows without any eviction policy at all.
Covered concretely in Observer Pattern's own memory-leak section: an observer that subscribes to a long-lived subject but is never unsubscribed stays reachable through the subject's observer list for as long as the subject lives, even if nothing else in the application still needs that observer.
class RequestContext {
private static final ThreadLocal<User> currentUser = new ThreadLocal<>();
static void set(User user) { currentUser.set(user); }
static User get() { return currentUser.get(); }
// missing: static void clear() { currentUser.remove(); }
}
In a thread-pool-based server (Tomcat, most Spring Boot deployments), threads are reused across many requests — a pooled thread that served request A and never had its ThreadLocal cleared still carries request A's data into request B, which served on the same reused thread later. Beyond the leak, this is also a correctness bug: request B could see request A's user context. The fix is always the same: clear every ThreadLocal in a finally block (or a filter's cleanup phase) at the end of each request, not just set it at the start.
Covered in JVM Memory Areas' classloader-delegation section: when a web application is redeployed without its old classloader being fully released — because a thread, a static field in a shared framework, or a registered JDBC driver still references something loaded by the old classloader — that entire old classloader, and every class and object it ever loaded, leaks. This is a specifically severe case because it's not one leaked object, it's an entire generation of loaded classes.
class Activity {
private byte[] largeData = new byte[100_000_000]; // 100MB
class InnerHandler { // non-static inner class
void onEvent() { /* uses Activity's state */ }
}
InnerHandler getHandler() { return new InnerHandler(); }
}
A non-static inner class holds an implicit reference to its enclosing instance (Activity.this), generated automatically by the compiler — even if InnerHandler never actually touches largeData or any other field of Activity. If an InnerHandler instance outlives its intent (e.g. registered as a long-lived callback somewhere), it keeps the entire enclosing Activity — including its 100MB field — reachable, purely because of the implicit reference, not because the inner class logically needs it. The fix, when the inner class doesn't need outer-instance access: make it a static nested class instead, which has no implicit outer reference at all.
String s = someUntrustedInput.intern(); // adds to the JVM's string pool if not already present
Before Java 7, the interned string pool lived in PermGen with a small, hard-to-tune size limit — unbounded interning of many distinct strings (e.g. interning arbitrary user input) could exhaust PermGen. Since Java 7 moved the string pool to the regular heap, this specific failure mode is far less common (the pool now grows against the much larger heap, managed by the regular GC), but unbounded interning of high-cardinality strings is still wasteful — it's just no longer the dedicated PermGen-exhaustion crisis it used to be.
Q: How would you find the actual leaking reference in production using a heap dump?
A: Generate a heap dump (jmap -dump, jcmd GC.heap_dump, or automatically via -XX:+HeapDumpOnOutOfMemoryError — full tooling covered in GC Tuning & Diagnostics), then load it into a tool like Eclipse MAT and use its "leak suspects" report, which finds unusually large object clusters and traces their dominator path back to a GC root — that root is almost always where the actual bug lives (a static field, an un-cleared ThreadLocal, an un-removed listener).
Q: Is every long-lived cache a memory leak?
A: No — a cache with an explicit eviction policy (size-bounded LRU, as in TreeMap & LinkedHashMap, or time-based expiry) is a deliberate, bounded memory tradeoff, not a leak. The distinguishing question is whether growth is bounded by design or unbounded by omission — the static SessionCache example above is a leak specifically because nothing bounds it.
Q: Why doesn't making InnerHandler static automatically break functionality that used to rely on the outer reference?
A: If InnerHandler genuinely needed access to Activity's state, making it static removes that implicit access — you'd need to pass an explicit reference (or just the specific data needed) into the static nested class's constructor instead. The fix only applies cleanly when the inner class doesn't actually need outer-instance access in the first place, which is worth verifying before reflexively making every inner class static.
Q: Could a WeakReference-based cache (from JVM Memory Areas) have prevented the SessionCache leak?
A: Partially — using a WeakHashMap keyed appropriately would let entries get collected once nothing else references the key, but it doesn't replace a real eviction policy (session timeout, LRU) for a cache whose keys (session IDs, likely simple strings) might otherwise stay reachable through unrelated paths — weak references solve 'nothing else needs this,' not 'this has been unused too long,' which are different conditions.