Why are unbounded static collections and caches a classic Java memory leak, and how do you prevent them?
answer
- Static field = GC root = forever reachable
- Append-only collection = unbounded growth
- Pinned entry pins everything it references (retained heap)
- Fix: bound size (LRU), TTL expiry, or weak/soft refs
- Caffeine/Guava for real caches; WeakHashMap for keyed side-tables
basics
~20 sA static collection lives for the whole program and is a GC root, so anything you put in it stays in memory forever unless you remove it. If you keep adding and never evicting, the heap grows without bound. Fix it by bounding the size or evicting old entries.
solid answer
~50 sA static field is a GC root: it is reachable for the entire life of its class, which is usually the whole application. So every object you put into a static collection (or a cache backed by one) stays reachable — and therefore uncollectable — until you explicitly remove it. If the code only ever adds entries (per request, per user, per key) and never evicts, the collection grows without bound and the retained heap climbs until OutOfMemoryError. The fix is to give the cache a bounded lifecycle: cap its size and evict (LRU via LinkedHashMap or a library like Caffeine/Guava), add time-based expiry, or use reference-based maps (WeakHashMap so entries vanish when keys become unreachable, or soft references for memory-sensitive caches). The deeper rule: a long-lived container must have an explicit eviction policy, otherwise it is an append-only leak.
code
java · 13 lines// Leak: append-only static map, no eviction
static final Map<String, Session> SESSIONS = new HashMap<>();
void onLogin(String id, Session s) { SESSIONS.put(id, s); } // never removed -> grows forever
// Fix A: bounded LRU
Map<String, Session> lru = new LinkedHashMap<>(16, 0.75f, true) {
@Override protected boolean removeEldestEntry(Map.Entry<String, Session> e) {
return size() > 10_000; // evict eldest beyond cap
}
};
// Fix B: tie removal to lifecycle
void onLogout(String id) { SESSIONS.remove(id); }go deeper
Knows a static collection that only grows will eventually run out of memory, and that you should remove old entries.
Explains that static fields are GC roots so entries stay reachable until removed; can implement an LRU bound or reach for Caffeine/Guava and knows TTL expiry exists.
Reasons about retained size and root paths from a heap dump, chooses between size-bound, TTL, WeakHashMap, and soft references by use case, and ties eviction to lifecycle events.
Establishes org-wide conventions: no unbounded caches, standard cache library with metrics (hit rate, size, evictions), capacity planning, and alerting on live-heap growth to catch slow leaks before OOM.
## Why this leaks — start from reachability The garbage collector frees an object only when it is **unreachable** — when no chain of references starting at a **GC root** can reach it. **Static fields are GC roots.** A `static` field belongs to the *class*, not to any instance, and the class normally stays loaded for the whole life of the application. So a `static Map`, `static List`, or any cache held in a static field is reachable from the very start to the very end of the program. That means: **every object you put into a static collection becomes reachable for as long as it stays in the collection.** The GC cannot touch it, no matter how 'done' your code is with it. The only way it becomes collectable is if you *remove it from the collection* (or the whole class unloads, which rarely happens). ## The leak pattern The trap is an **append-only** collection: ```java public class SessionRegistry { private static final Map<String, Session> SESSIONS = new HashMap<>(); public static void register(String id, Session s) { SESSIONS.put(id, s); // we add on every login... } // ...but nothing ever calls remove() on logout/expiry. } ``` Every login adds an entry; nothing ever evicts. The map (a GC root chain) grows forever. Each `Session` — and everything *it* references (user data, buffers, downstream objects) — is pinned in the heap. The **retained heap** (memory kept alive because of these references) climbs steadily with traffic until `OutOfMemoryError: Java heap space`. The same shape appears as a hand-rolled **cache**: `static Map<Key, Value>` that memoizes results but never expires anything. It works fine in a quick test and leaks in production where the key space is large or unbounded. ## Why 'unbounded' is the dangerous word A static collection of *fixed, small* size (say, a lookup table loaded once) is fine — it stops growing. The leak is specifically **unbounded growth**: the number of entries scales with requests, users, time, or some other ever-increasing input, with no removal. ## How to detect it - Watch the **post-GC (live) heap** trend upward over hours/days even at steady load. - Take a **heap dump** and look at **retained size**: a single static map dominating the heap, with millions of entries, is the smoking gun. The GC-root path will point straight at the static field. ## How to prevent / fix it Give every long-lived container an **explicit eviction policy**: 1. **Bound the size.** Cap entries and evict the least-recently-used. `LinkedHashMap` with `accessOrder=true` and an overridden `removeEldestEntry` gives a simple LRU; production code usually reaches for **Caffeine** or **Guava Cache**. 2. **Time-based expiry.** Evict entries after a TTL (expire-after-write/access), so stale entries don't accumulate. 3. **Reference-based maps.** A **`WeakHashMap`** holds its *keys* weakly: once a key is otherwise unreachable, the entry is removed automatically at the next GC — good for canonicalizing/metadata side-tables. For a cache you're willing to drop under memory pressure, use **`SoftReference`** values (soft refs are cleared only when the JVM is low on memory). 4. **Tie lifecycle to a real event.** If entries correspond to sessions/connections, remove them on logout/close/expiry rather than relying on GC. ## The principle to remember A static collection is **forever-reachable storage**. If what you store has a shorter natural lifetime than the application, you *must* define when it leaves the collection. 'Add but never remove' on a static container is a leak by construction.
- How does WeakHashMap help, and what is its main limitation?WeakHashMap holds its keys via weak references, so when a key is no longer strongly reachable elsewhere, the entry is removed at the next GC automatically. Limitation: it only helps if the key's lifetime is what bounds the entry, and the values are still strongly held until the entry is removed; it is not a general-purpose size/time cache.
- Soft vs. weak references for a cache — which and why?Soft references are cleared only under memory pressure (before OOM), so soft-valued caches act as memory-sensitive caches that shrink when the heap is tight. Weak references are cleared eagerly at the next GC once the referent isn't strongly reachable, which is too aggressive for a cache you want to keep around. So caches usually prefer soft (or a bounded size/TTL cache) over weak.
saying these in an interview costs you the question
- Assuming a cache 'cleans itself up' without an explicit eviction policy
- Using a plain static HashMap as a cache with no size or time bound
- Thinking WeakHashMap holds values weakly (it holds keys weakly)
- Believing the GC will collect entries that are still in the map