How do you get a memory leak in a garbage-collected language?
The collector frees what is unreachable, not what is finished with — so a leak in Java is unwanted reachability, not a missing free(). Four structures cause almost all of them: a static collection, a registered listener nobody unregisters, a ThreadLocal on a pooled thread, and a ClassLoader held by one stray reference. None of them throws anything until the heap is gone.
The Answer
- Java's collector frees what is unreachable, not what you have finished with. A leak is an object you no longer need that something still points at.
- There is no missing
free()to find. You are looking for a reference path from a GC root — a static field, a live thread, a stack frame, a JNI handle. - Four structures cause nearly all of them: a static collection that only grows; a listener or callback registered and never removed; a
ThreadLocalon a pooled thread, which never dies; and aClassLoaderkept alive by one stray reference. - The symptom is not an exception. It is heap usage after a full GC creeping up across runs, and
OutOfMemoryErrordays later. - The diagnostic is a heap dump and the dominator tree — not a profiler's allocation view, which shows what was created rather than what is retained.
- A cache without an eviction policy is not a cache. It is a leak with a hit rate.
Understand It
Reachability, demonstrated
The rule is short enough to prove in one block. A WeakReference does not keep its referent alive, so it clears as soon as the object becomes unreachable — which makes it a probe for exactly the property that matters:
Object unreferenced = new Object();
WeakReference<Object> probe1 = new WeakReference<>(unreferenced);
unreferenced = null; // last strong reference gone
System.out.println("unreachable object collected : " + collected(probe1));
Object cached = new Object();
WeakReference<Object> probe2 = new WeakReference<>(cached);
CACHE.add(cached); // a static list now points at it
cached = null; // the local reference goes too
System.out.println("same object, but in CACHE : " + collected(probe2));
System.out.println(" CACHE.size() : " + CACHE.size());unreachable object collected : true
same object, but in CACHE : false
CACHE.size() : 1Two identical objects, both with their local reference set to null, and opposite fates. Nothing about the object decided that. The only difference is whether a path to it still exists from a GC root — and CACHE is static, so its class is a root and the path is permanent.
That is the whole mechanism. Every leak below is a variation on the second line.
Why a static collection is the archetype
static final Map<String, Session> SESSIONS = new HashMap<>() is written in every codebase, usually as a cache, usually by someone who meant it to be temporary. It has three properties that combine badly:
- The class is a GC root, and application classes are almost never unloaded, so the map lives for the life of the JVM.
- Nothing bounds it. Entries go in on a code path that runs per request; nothing takes them out.
- It works perfectly in every test, because tests do not run for four days.
The fix is not "avoid static". It is to decide, in the same commit that adds the map, what removes an entry. If you cannot answer that, you have not designed a cache. The three real answers are a size bound with eviction, a time bound with expiry, or weak keys — and the third is the one people reach for first and should reach for last, because WeakHashMap keys are cleared on a schedule you do not control and values commonly reference their own keys, which defeats it entirely.
ThreadLocal on a pooled thread, the one that surprises people
A ThreadLocal is cleaned up when its thread dies. That is a perfectly good rule right up until the thread belongs to a pool — because pool threads are built not to die.
ExecutorService pool = Executors.newFixedThreadPool(1);
pool.submit(() -> TENANT.set("acme")).get(); // request 1
String seenByNextTask = pool.submit(TENANT::get).get(); // request 2
System.out.println("set by request 1, read by request 2 : " + seenByNextTask);
pool.submit(TENANT::remove).get();
System.out.println("after remove() in a finally block : " + pool.submit(TENANT::get).get());
pool.shutdown();set by request 1, read by request 2 : acme
after remove() in a finally block : nullRead the first line again: the second request saw the first request's tenant. This is not only a memory leak, it is a correctness and security bug, and it is how tenant data, user identity or a security context crosses a request boundary in a thread-pooled server. The leak is the same fact wearing a different hat — the value is retained for the life of the pool, which is the life of the application.
ThreadLocal.remove() in a finally is the whole fix, and it has to be finally, because the interesting case is the request that threw.
Two things worth knowing beyond the fix. ThreadLocalMap uses weak keys, which is why people believe this is handled for them — but only the key is weak; the value is held strongly until the entry is cleaned up, and that cleanup happens opportunistically on other ThreadLocal operations that may never come. And Java 21+a virtual thread is not pooled — it really does die after its task, so this class of leak genuinely goes away there. ScopedValue is the intended replacement for the pattern.
The listener that outlives everything it was listening for
// Registration is one line and looks harmless.
eventBus.register(this);
// `this` is now reachable from the bus, which is usually a singleton. If
// `this` is a UI component, a request-scoped bean or a per-connection
// handler, the bus now retains it — and everything it references — forever.
The tell is that the leaked object is often large: a handler that holds a session, which holds a user, which holds a cached document. One unremoved listener retains a subgraph.
This is also the case where an inner class turns a small leak into a big one. A non-static inner class holds a hidden reference to its enclosing instance, so registering new Handler() { … } from inside a big object retains the big object even though the handler itself is tiny. A static nested class, or a lambda that captures nothing, does not.
What a ClassLoader leak actually costs
The worst version, because it defeats the usual reasoning. Redeploy an application in a container without restarting the JVM, and the old application's ClassLoader should become unreachable and take every class it loaded with it. One reference from outside is enough to stop that — a ThreadLocal set on a container thread, a JDBC driver registered in DriverManager, a shutdown hook, a live thread the application started and never stopped.
The result is that every class, every static field and the whole of the old application stays in memory, and a second redeploy doubles it. Java 8+The class metadata lands in Metaspace, which is native memory and unbounded by default, so instead of failing predictably at a size you configured, the process grows until the kernel kills it. That is a worse failure than the PermGen one it replaced, and it is why -XX:MaxMetaspaceSize is worth setting even though nothing forces you to.
Finding one, in the order that actually works
The mistake is to open a profiler and look at allocation. Allocation tells you what was created; a leak is about what is retained, and the objects retained are usually boring types — String, HashMap$Node, Object[] — created by code that is not at fault.
1. Confirm it is a leak, not a workload.
Turn on GC logging and look at heap used AFTER each full GC. A workload
that needs more memory has a high, flat floor. A leak has a rising one.
2. Take two heap dumps, an hour or a run apart.
jcmd <pid> GC.heap_dump /tmp/1.hprof
3. Compare, and sort by RETAINED size, not shallow size.
The dominator tree answers "if this went away, how much would be freed" —
which is the only question that matters.
4. Follow the path to a GC root from the biggest dominator.
Eclipse MAT calls it "merge shortest paths to GC roots". That path IS the
bug. It usually ends at a static field, a Thread, or a ClassLoader.
Step 3 is where people go wrong. Shallow size says String is using 400 MB, which is true and useless. Retained size says one SessionCache is responsible for 400 MB, which is the answer.
Reference
The four shapes, and the fix for each
| Leak | How it holds on | Fix |
|---|---|---|
| Static collection | class is a GC root, map only grows | bound it: size + eviction, or TTL. Caffeine, or LinkedHashMap.removeEldestEntry |
| Listener / callback | registry holds the subscriber strongly | unregister in the same lifecycle method that registered; or weak subscriptions if the framework offers them |
ThreadLocal on a pool | pooled threads never die | remove() in finally, always. Virtual threads or ScopedValue remove the class of bug |
ClassLoader | one outside reference pins the whole app | stop threads, deregister JDBC drivers, clear ThreadLocals on container threads at shutdown |
| Mutated map key | entry sits in the bucket of its old hash | immutable keys — see hashmap-internals |
| Unbounded queue | ExecutorService with LinkedBlockingQueue | bounded queue plus a rejection policy that is not "silently drop" |
A cache with a ceiling, in the JDK only
// Least-recently-used, bounded, no dependencies. accessOrder=true is what
// makes it LRU rather than insertion-ordered.
Map<String, Session> cache = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Session> eldest) {
return size() > 10_000;
}
};
// Not thread-safe. Wrap it, or use Caffeine, which also gives you TTL,
// refresh, and statistics you will want the first time you have to argue
// about hit rate.
Map<String, Session> shared = Collections.synchronizedMap(cache);
The flags you want set before the incident
-XX:+HeapDumpOnOutOfMemoryError # the dump you cannot take afterwards
-XX:HeapDumpPath=/var/log/app/
-XX:MaxMetaspaceSize=256m # unbounded by default; bound it
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime:filecount=5,filesize=10M
# Live, on a running process.
jcmd <pid> GC.heap_info # used vs committed vs max
jcmd <pid> GC.class_histogram # instance counts by class, cheap
jcmd <pid> GC.heap_dump /tmp/heap.hprof # the real thing; pauses the JVM
jcmd <pid> Thread.print # a leak is often a thread nobody stopped
jcmd <pid> VM.native_memory summary # needs -XX:NativeMemoryTracking=summary
GC.class_histogram is the underrated one: it is fast, it does not need a dump file moved off the host, and "there are 4 million SessionEntry objects" is frequently the entire diagnosis.
Reference strengths, and when each is right
| Type | Collected when | Use for |
|---|---|---|
| strong | unreachable | everything, by default |
SoftReference | unreachable and memory is tight | a genuine memory-sensitive cache — but the JVM decides, so you cannot reason about the hit rate |
WeakReference | unreachable from anywhere strong | canonicalising maps, listener registries, and proving reachability as above |
PhantomReference | after collection, via a ReferenceQueue | native cleanup. Use Cleaner, which wraps this correctly |
Reaching for SoftReference to fix a leak is nearly always wrong. It converts a leak into unpredictable GC pressure, and it hides the design question — what should be evicted, and when — rather than answering it.
Scenarios
A batch job gets slower every night and nobody changed it. Look at heap used after full GC across runs, not peak. A rising floor on identical input is a leak; a high flat floor is a workload that needs a bigger heap. If it is rising, raising -Xmx buys exactly one more run. Two dumps and a dominator tree will usually name a static collection within ten minutes.
Tenant data appears under the wrong tenant, intermittently, under load. Almost always a ThreadLocal without remove() in a finally, on a pooled thread — a request that threw left its value behind for whichever request got that thread next. Treat it as a security incident rather than a memory one: the leak is the secondary symptom. Audit every ThreadLocal.set for a matching remove, and check the framework's filter ordering, because a remove() in a filter that the exception skipped is not a fix.
Someone proposes WeakHashMap for the session cache. Push back, and name the specific reason rather than the general one. Weak keys only help if nothing else strongly references the key — and here the value (a Session) usually references the key (a user id or the user object), which pins the entry forever and defeats the whole thing. Worse, eviction becomes unpredictable, so a session might vanish between two requests on a quiet JVM and survive for hours on a busy one. A size bound with a TTL is boring, testable, and correct.
Metaspace grows on every redeploy but the heap looks healthy. A ClassLoader leak, and the heap looking fine is the diagnostic — the retained objects are classes, not instances. Check for threads the application started and did not stop, JDBC drivers left in DriverManager, and ThreadLocals set on container-owned threads. The honest answer in many shops is also worth saying out loud: restarting the process on deploy makes the whole class of bug disappear, and if you are deploying containers you already do.
Interviewer's Next Move
1. "Java has a garbage collector. How can it leak at all?"
Because the collector's job is to free what is unreachable, not what is finished with. If a static map, a live thread, or a registered listener still has a path to an object, it is reachable by definition and the collector is behaving correctly. A leak in Java is a design bug — unwanted reachability — rather than a missing free().
2. "Name the four you would check first."
Static collections that only grow; listeners or callbacks registered and never removed; ThreadLocals on pooled threads; and ClassLoader leaks in anything that redeploys without restarting. Between them they cover the large majority of real incidents, and each has a different fix.
3. "Why is a ThreadLocal a leak on a thread pool but not on a plain thread?"
A ThreadLocal's entries are cleaned up when the thread dies, and pooled threads are built not to die. The map's keys are weak, which is why people think it is handled, but the value is held strongly until the entry is cleaned up — and that cleanup is opportunistic. remove() in a finally is the fix. Virtual threads do not pool, so the problem does not arise.
4. "You have a heap dump. What do you look at first?"
Retained size, via the dominator tree — "if this object went away, how much would be freed". Shallow size just tells you String and Object[] are big, which is true in every application. Then trace the shortest path to a GC root from the biggest dominator; that path is the bug, and it usually terminates at a static field, a thread, or a class loader.
5. "Is a cache a leak?"
A cache without an eviction policy is a leak with a hit rate. The question to ask about any cache is what removes an entry — a size bound, a time bound, or an explicit invalidation. If the answer is "nothing", it is unbounded storage on a code path that runs per request, and the only thing standing between it and an OutOfMemoryError is how long the process happens to stay up.
Code traps
class Registry {
private static final List<Handler> HANDLERS = new ArrayList<>();
static void register(Handler h) { HANDLERS.add(h); }
}
class RequestScope {
private final byte[] payload = new byte[10 * 1024 * 1024];
RequestScope() {
Registry.register(event -> System.out.println(payload.length));
}
}
Answer
Every RequestScope leaks 10 MB. The lambda captures payload, so it holds a reference to the enclosing instance's field, and HANDLERS is static — so every request permanently retains its payload. A lambda that captures nothing would not (it is a singleton), and neither would one capturing only payload.length. The lesson is that what the lambda captures decides what it retains, and it is not visible at the registration site.
String tiny = hugeFileContents.substring(0, 10);
hugeFileContents = null;
Answer
On Java 7 and later, nothing leaks: substring copies. On Java 6 it shared the parent's char[] with an offset and length, so tiny retained the entire file — the single most-cited accidental leak in Java's history, and the reason new String(tiny) used to be a real defensive idiom. It is worth knowing because it still appears in interview questions written from old material, and the correct answer today is "that was fixed in 7".
Common wrong answers
| Said in interviews | Reality |
|---|---|
| "Java can't leak, it has GC." | GC frees the unreachable. Reachable-but-unwanted is the whole category. |
"Call System.gc() to fix it." | It is a hint, and a leaked object is reachable — no collector will free it, however often you ask. |
| "Set the object to null to free it." | Only helps if that was the last reference. In a leak, by definition, it is not. |
"Use SoftReference for caches." | It converts a design problem into unpredictable GC behaviour. Bound by size or time instead. |
"WeakHashMap fixes cache leaks." | Only when nothing else references the key — and the value usually does. |
"ThreadLocal is cleaned up automatically." | When the thread dies. Pool threads do not die. |
| "A profiler's allocation view finds leaks." | It shows what was created. A leak is about what is retained — use a dump and retained size. |
Check Yourself
Q1. Two identical objects, both with their local variable set to null. One is collected and one is not. What is the only thing that can differ?
Answer
Whether some other path from a GC root still reaches it — a static field, a live thread's stack, a registered listener, a JNI handle. Reachability is the sole criterion; nothing about the object itself, its size, its age or its class affects whether it is collectable.
Q2. Your service shows tenant data under the wrong tenant, intermittently, only under load. Where do you look first and why?
Answer
A ThreadLocal without remove() in a finally, on a pooled thread. A request that threw left its value behind, and the next request to be handed that thread read it. It is the same mechanism as the leak, but the correctness bug is the more urgent half — treat it as a security incident.
Q3. Heap after full GC is flat and high, not rising. Is that a leak?
Answer
No — that is a workload that genuinely needs that much live data, and the answer is a bigger heap or less live data. A leak shows a rising floor across runs of the same input. Distinguishing the two before touching anything is what stops you spending a day in a heap dump that has nothing wrong in it.
Practice
| Tier | Exercise | Time |
|---|---|---|
| Warm-up | Two objects, one collected | 5 min |
| Challenge | Bound the cache | 20 min |
| Production | The tenant that leaked across requests | 45 min |
| Interview | Full round replay — leaks | 10 min |
What changed, and when
Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.
- Java 7
String.substring() copies its characters instead of sharing the parent's array, so holding a short substring of a huge string stops retaining the whole thing.
Before Java 7 (now gone): substring() shared the backing char[] with a new offset and count, so one 10-character substring could retain a 10 MB string forever. It was the textbook accidental leak.
- Java 8LTS
PermGen is gone, so a ClassLoader leak fills Metaspace — native memory, unbounded by default — instead of a fixed region.
Before Java 8 (now gone): A redeploy loop exhausted PermGen at a size you had set yourself, which at least failed early and predictably with OutOfMemoryError: PermGen space.
- Java 9
Cleaner replaces finalize() for native cleanup, with the resource's state in a separate object so the cleanup action cannot accidentally reference the thing it is cleaning up.
Before Java 9: finalize() ran on an unbounded queue with no timing guarantee, and a slow finalizer could stall the whole queue — making the cleanup mechanism itself a leak.
Practice ladder
Reading this page is not knowing it. Four tiers, ending in a real incident.
- Warm-up5 min
Two objects, one collected
One concept, guided. Near-impossible to fail.
- Challenge20 min
Bound the cache
Edge cases. You have to reason, and two valid fixes differ.
- Production incident45 min
The tenant that leaked across requests
A real incident: symptom first, cause hidden, tradeoff at the end.
- Interview replay10 min
Full round replay — leaks
Timed verbal replay with pass/fail criteria per follow-up.
Where this question goes next
Questions that lead here
What lives on the heap and what lives on the stack?
Objects go on the heap, shared by every thread and collected by the GC. Frames go on the stack, one per thread, popped on return. The rule everyone recites — primitives on the stack, objects on the heap — holds only for locals: an int field lives inside its object, on the heap. And it is not absolute, because escape analysis deletes allocations the JIT proves never escape.
Asked constantlyintermediate1–10 yrs12 min readJvmWhat replaced PermGen, and why?
Java 8 removed PermGen and moved class metadata to Metaspace, in native memory outside the heap. The failure did not go away — it moved. PermGen had a size you set yourself and failed predictably; Metaspace is unbounded by default, so the same leak now grows until the kernel kills the process, with no Java-side error and no heap dump.
Asked oftensenior3–15 yrs10 min readJvm
Every runnable example above was compiled and executed against openjdk 21.0.12 on this build, and its output diffed against what this page claims. Last updated 2026-09-13.