⚙️ JVM Internals & Memory · Advanced

Memory leaks in Java

Static collections, listeners, caches, ThreadLocals, inner classes.

🧩 The mystery"Java has a garbage collector, so it can't leak memory." Then why does your service need a restart every Tuesday?

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.

🔮 Predict it

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());
}
  1. 2
  2. 4
  3. 6
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

✗ Leaks
CURRENT.set(loadUser(req));
process(req);

Pool threads live forever, so the User stays reachable, and can leak into the next request.

✓ Clean
CURRENT.set(loadUser(req));
try {
    process(req);
} finally {
    CURRENT.remove();
}

remove() in finally always clears the slot.

⚠️ The trap

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
}
🤔 Think first

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.

💼 In the real world

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

  1. Leak = reachable but useless objects
  2. Bound caches and remove listeners
  3. Always ThreadLocal.remove() in a finally block in pools
  4. 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.

🤯 Did you know?

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. 1
  2. 0
  3. 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);
}
  1. Make CURRENT a non-static field
  2. Wrap the work in try/finally and call CURRENT.remove() in finally
  3. Call System.gc() at the end of handle()
  4. 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.

Leaks end the same way: OutOfMemoryError. But its message comes in different flavors. Next: how to read them.