Memory leaks in Java
Static collections, listeners, caches, ThreadLocals, inner classes.
Reachable but useless
The GC only frees unreachable objects. A Java memory leak is objects that stay reachable although nobody needs them anymore. The GC isn't allowed to touch them, so memory grows until OutOfMemoryError.
How many keys?
Key has no equals()/hashCode(); RKey is a record. What does this print?
class Key { String id = "u1"; }
record RKey(String id) { }
void main() {
var set = new HashSet<Object>();
for (int i = 0; i < 3; i++) {
set.add(new Key());
set.add(new RKey("u1"));
}
System.out.println(set.size());
}246
Show the answer
Records generate equals() and hashCode(), so the three RKey("u1") count once. Key uses identity, so each new Key() is a new entry: 3 + 1 = 4. A cache keyed like Key grows forever.
The usual suspects
Static collections and caches without eviction. Listeners that are registered but never removed. ThreadLocals in pooled threads. Non-static inner classes that secretly hold their outer object. And map keys without correct equals()/hashCode().
ThreadLocal on a thread pool
CURRENT.set(loadUser(req));
process(req);Pool threads live forever, so the User stays reachable, and can leak into the next request.
CURRENT.set(loadUser(req));
try {
process(req);
} finally {
CURRENT.remove();
}remove() in finally always clears the slot.
The listener that never leaves
this::onEvent captures the Dashboard, and the long-lived bus keeps that listener forever. close() only hides the screen, so every dashboard ever opened stays in memory. close() must also unregister the listener.
class Dashboard {
Dashboard(EventBus bus) {
bus.register(this::onEvent);
}
void onEvent(Event e) { redraw(); }
void close() { hide(); } // no unregister
}The tiny task, the huge screen
A small Task from a non-static inner class is submitted to a long-lived executor. The heap dump shows the huge outer Screen retained. Why?
Think about it, then reveal the answer
Every inner (non-static) class instance holds a hidden reference to its outer object (Screen.this). Use a static nested class or a record when the outer object isn't needed.
Spotting a leak in production
The tell-tale sign: after each full GC, the memory baseline is a bit higher than last time. Take a heap dump, sort by retained size, and follow the path to GC root. It usually ends at a static map, a registry or a thread pool.
Key takeaways
- Leak = reachable but useless objects
- Bound caches and remove listeners
- Always ThreadLocal.remove() in a finally block in pools
- Map keys need correct equals() and hashCode()
💡 A leak is like a hotel that never checks guests out: the rooms are technically occupied, so no one else can use them.
If a HashMap key's hashCode changes after you insert it, get() and remove() can no longer find the entry, yet it stays in the map: a leak you can't even remove by key.
Practice questions
What does this print?
class Key {
String id;
Key(String id) { this.id = id; }
}
void main() {
Map<Key, String> cache = new HashMap<>();
for (int i = 0; i < 3; i++)
cache.put(new Key("user-1"), "data");
System.out.println(cache.size());
}- 1
- 0
- 3
Check your answer
3. Key doesn't override equals() and hashCode(), so each new Key("user-1") is a different map key. A cache keyed like this grows forever.
This runs on a thread pool. Over time, old User objects pile up. What's the best fix?
static final ThreadLocal<User> CURRENT =
new ThreadLocal<>();
void handle(Request req) {
CURRENT.set(loadUser(req));
process(req);
}- Make CURRENT a non-static field
- Wrap the work in try/finally and call CURRENT.remove() in finally
- Call System.gc() at the end of handle()
- Use InheritableThreadLocal instead
Check your answer
Wrap the work in try/finally and call CURRENT.remove() in finally. Pool threads live forever, so whatever a ThreadLocal holds stays reachable until it's removed. Removing in finally also stops one request's user from leaking into the next.