Hook: In Java, a memory leak is usually not missing free(); it is a forgotten reference that keeps objects alive, like a broom closet whose door is still bolted shut from the inside.
Question: How do you identify and fix a memory leak?
Answer: I first confirm that memory is being retained, not just used heavily during a spike. In Java, a leak usually means objects are still reachable through a strong reference (a normal reference that keeps an object alive), so the garbage collector cannot reclaim them. Then I use a heap dump, GC logs, or a profiler to find the retention path, fix the code that keeps the reference, and rerun the same load to prove the heap settles down.
Interview-Ready Answer: I would start by reproducing the problem under load and checking whether heap usage keeps rising after GC. Then I’d take a heap dump and look at the largest retained objects and their GC roots, because that tells me what is still holding them alive. Once I find the retention path, I fix the source — for example, a static collection, forgotten listener, or uncleared ThreadLocal — and I verify the fix by running the same test again and confirming the heap and GC pauses stay stable.
A Java memory leak is almost always object retention: data that should be dead is still reachable from something alive. The garbage collector can only reclaim objects that are no longer reachable from GC roots — the starting points the JVM treats as live, such as thread stacks, static fields, JNI references, and active local variables.
jcmd <pid> GC.class_histogram for a quick snapshot, or take a heap dump with jmap -dump:live,format=b,file=heap.hprof <pid>. A heap dump is heavy, but it shows who is holding memory.ThreadLocal values in thread pools, bounding caches, and closing native resources.| Tool | Best for | Trade-off |
|---|---|---|
| GC logs | Seeing rising pauses | Shows symptoms, not the holder |
| Class histogram | Quick top classes | No reference path |
| Heap dump | Finding retention roots | Large file, JVM pause |
| Profiler/JFR | Allocations over time | Some runtime overhead |
Time-wise, a histogram is the cheapest first pass, a profiler usually adds low single-digit to low double-digit overhead, and a heap dump is the most expensive because it is roughly proportional to heap size. That is why interviewers like this question: the best engineers do not guess; they narrow the problem, prove the cause, and then verify the fix.
ThreadLocal leaks are common in servlet and executor thread pools because threads live a long time.Real-world story: In an e-commerce checkout service, each request stored a small session helper in a static map so later code could read it. During normal traffic it looked fine, but on a big sale the heap kept climbing, Full GC ran every few seconds, and the pods restarted with java.lang.OutOfMemoryError: Java heap space. The root cause was not the garbage collector; it was unbounded retention. The fix was to remove the static map, make the data request-scoped, and use a bounded cache with a TTL where persistence was actually needed.
What goes wrong when people misunderstand this: they add more heap and delay the failure, but p95 latency rises, logs fill with long GC pauses, and customers see timeouts at checkout.
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public class MemoryLeakDemo {
// Static state is the easiest way to accidentally keep objects alive forever.
// Anything stored here stays reachable for the lifetime of the JVM unless we remove it.
private static final List<HeavyListener> REGISTRY = new ArrayList<HeavyListener>();
public static void main(String[] args) throws Exception {
String mode = args.length == 0 ? "leak" : args[0].toLowerCase(Locale.ROOT);
if ("leak".equals(mode)) {
runLeakDemo();
} else if ("fix".equals(mode)) {
runFixedDemo();
} else {
System.out.println("Usage: java MemoryLeakDemo [leak|fix]");
}
}
private static void runLeakDemo() throws InterruptedException {
System.out.println("=== Leak demo ===");
printMemory("start");
try {
for (int i = 1; i <= 120; i++) {
REGISTRY.add(new HeavyListener(i));
// Periodic reporting makes the growth obvious without requiring a profiler.
if (i % 20 == 0) {
printMemory("after registering " + i);
}
}
} catch (OutOfMemoryError e) {
// In a real service you would not try to recover from this;
// this catch just makes the failure path visible in a demo.
System.out.println("Ran out of heap because retained objects kept growing: " + e);
}
System.out.println("Registry size: " + REGISTRY.size());
forceGc();
printMemory("after GC");
System.out.println("Because REGISTRY is static, those listeners are still strongly reachable.");
}
private static void runFixedDemo() throws InterruptedException {
System.out.println("=== Fixed demo ===");
printMemory("start");
List<HeavyListener> temp = new ArrayList<HeavyListener>();
for (int i = 1; i <= 120; i++) {
temp.add(new HeavyListener(i));
}
printMemory("after allocation");
// The important fix is to remove the last strong references.
// Once the list is cleared, the listeners become eligible for GC.
temp.clear();
forceGc();
printMemory("after clear + GC");
System.out.println("The objects can now be reclaimed because nothing keeps them reachable.");
}
private static void forceGc() throws InterruptedException {
// System.gc() is only a hint, not a command, but it is useful in a small demo.
System.gc();
Thread.sleep(200);
}
private static void printMemory(String label) {
Runtime rt = Runtime.getRuntime();
long usedMb = (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024);
long totalMb = rt.totalMemory() / (1024 * 1024);
long maxMb = rt.maxMemory() / (1024 * 1024);
System.out.println(label + " | used=" + usedMb + "MB, total=" + totalMb + "MB, max=" + maxMb + "MB");
}
private static final class HeavyListener {
private final int id;
private final byte[] payload = new byte[256 * 1024]; // 256 KB each: big enough to show growth, small enough to run safely.
HeavyListener(int id) {
this.id = id;
// Touch the array so the allocation is definitely real and not optimized away.
this.payload[0] = (byte) id;
}
@Override
public String toString() {
return "HeavyListener#" + id;
}
}
}
Follow-up & Tricky Questions:
ThreadLocal leak? Remove the value with remove() when the request is done, especially in thread pools where threads live much longer than the request.Tricky question: Does calling System.gc() fix a memory leak? No. It may trigger a collection, but it cannot free objects that are still reachable through a live reference.
Tricky question: Is every large cache a leak? No. A bounded cache with an eviction policy is intentional; a leak is memory that should have been released but was accidentally kept.
Tricky question: Can a Java app leak memory even when the heap looks stable? Yes. Native memory, direct buffers, and class loader leaks can grow outside the normal heap view, so you may need native memory tracking or OS-level metrics too.
Common Mistakes:
Memory Hook: If one strong reference still points at it, the object is still invited to the party. GC can only clean up what nobody is reaching for.
Cheat Sheet:
ThreadLocal values.Practice Tasks:
ThreadLocal.remove().