Hook: Interviewers love this question because a real Java leak often looks like a slow GC problem long before it becomes an outage.
Question: How do you identify a memory leak?
Answer: In Java, a memory leak usually means objects are still strongly reachable, so the garbage collector cannot reclaim them. I identify it by watching for a heap that keeps rising over time, especially after a full GC, along with longer GC pauses or eventual OutOfMemoryError. Then I reproduce the workload, take a heap dump, and use a tool like Eclipse MAT to follow the retaining path from GC roots to the unexpected owner.
Interview-Ready Answer: I first confirm it is really a leak and not just normal allocation pressure. My rule of thumb is: after a full GC, used heap should drop and then settle; if it keeps climbing with the same traffic, that is suspicious. Then I capture GC logs and a heap dump, and I inspect the dominator tree and paths to GC roots in a tool like MAT to find what is holding the objects alive. If the heap looks fine but process memory still grows, I also check off-heap memory, direct buffers, and metaspace. The key idea is that a leak in Java is usually a retained reference, not just high memory usage.
A Java leak does not mean the JVM has no garbage collector. It means some object is still reachable through a reference chain, so the GC must treat it as live. A classic beginner mistake is to think, 'memory went up, so it is a leak' — but sometimes the app is just handling a spike, building a cache, or allocating many short-lived objects.
-Xlog:gc*; on Java 8, the classic flags are -XX:+PrintGCDetails -XX:+PrintGCDateStamps. Then capture a heap dump when memory is high, usually with jcmd <pid> GC.heap_dump /path/app.hprof. A live histogram with jcmd <pid> GC.class_histogram is a quick clue, but it is not enough to prove a leak.ThreadLocal values that are never removed, listener lists, queues that grow faster than they drain, and classloader leaks in app servers. If memory is in direct buffers or other native memory, the heap dump may not explain the full process growth, so check native memory too.ThreadLocal, or close the resource, rerun the same workload. A real fix changes the trend: the heap stops climbing and eventually reaches a stable steady state.| Case | After full GC | Main clue | Where to inspect |
|---|---|---|---|
| Memory leak | Still rises | Retained refs | Heap dump, GC roots |
| Allocation pressure | Drops then rises | Many short-lived objects | GC logs, profiler |
| Native leak | Heap may look normal | Process RSS rises | NMT, direct buffers |
One useful detail interviewers like: heap dump creation is roughly O(heap size) in time and disk I/O, so a 2-8 GB heap can take seconds to tens of seconds to dump, and several minutes to inspect well. JFR is usually low overhead for production, while a heap dump is more intrusive, so I prefer to observe live first, dump second.
ThreadLocal because thread pools keep threads alive for a long time.System.gc() as proof of anything; it may trigger collection, but it cannot free objects that are still referenced.Memory model to remember: the GC only removes what nobody can reach. If there is a path from a GC root to the object, it is still alive.
Imagine a Java checkout service during a holiday sale. Traffic is normal at first, but after two hours the service gets slower, p99 latency rises, and logs show more frequent GC pauses. Eventually the app logs java.lang.OutOfMemoryError: Java heap space, and the pod is restarted by Kubernetes.
When the team takes a heap dump, MAT shows a large ConcurrentHashMap retained by a static singleton cache. The cache stores user session data keyed by token, but there is no eviction policy and no TTL (time to live, meaning entries expire after a set time). Every request adds more entries, so the heap never settles. The impact is visible to users: checkout requests time out, abandoned carts increase, and support sees complaints that the site is 'randomly' slow before it crashes.
The misunderstanding that causes the outage is thinking 'we have a cache, so memory growth is normal.' A good cache is bounded and deliberate; a leak is accidental and unbounded. The practical symptom is that memory usage keeps rising even when traffic level stays the same.
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryUsage;
import java.util.ArrayList;
import java.util.List;
public class MemoryLeakDemo {
// A static collection is a common source of leaks because it lives for the life of the JVM.
// If we keep adding objects here and never remove them, they stay strongly reachable.
private static final List<byte[]> LEAKY_CACHE = new ArrayList<>();
private static final int ITERATIONS = 20;
private static final int CHUNK_KB = 512;
public static void main(String[] args) throws Exception {
boolean fixedMode = args.length > 0 && "fixed".equalsIgnoreCase(args[0]);
if (args.length > 0 && !fixedMode && !"leaky".equalsIgnoreCase(args[0])) {
System.out.println("Usage: java MemoryLeakDemo [leaky|fixed]");
return;
}
System.out.println("Starting heap: " + heapReport());
if (fixedMode) {
runFixedScenario();
} else {
runLeakyScenario();
}
}
private static void runLeakyScenario() throws InterruptedException {
for (int i = 1; i <= ITERATIONS; i++) {
byte[] block = new byte[CHUNK_KB * 1024];
block[0] = 1; // Touch the array so the allocation is real and not optimized away.
LEAKY_CACHE.add(block); // The bug: a long-lived reference keeps every array alive.
if (i % 5 == 0) {
printHeap("leaky batch " + i);
}
Thread.sleep(50);
}
forceGc();
printHeap("after forced GC in leaky mode");
System.out.println("Cache size retained = " + LEAKY_CACHE.size());
System.out.println("Notice that GC cannot reclaim these objects because the list still references them.");
}
private static void runFixedScenario() throws InterruptedException {
for (int i = 1; i <= ITERATIONS; i++) {
byte[] block = new byte[CHUNK_KB * 1024];
block[0] = 1;
// No retention: when the loop iteration ends, the array becomes eligible for GC.
// This is the key difference between normal allocation and a leak.
if (i % 5 == 0) {
printHeap("fixed batch " + i);
}
Thread.sleep(50);
}
forceGc();
printHeap("after forced GC in fixed mode");
System.out.println("In fixed mode the heap should settle because nothing is holding the arrays alive.");
}
private static void printHeap(String label) {
System.out.println(label + " -> " + heapReport());
}
private static String heapReport() {
MemoryMXBean mxBean = ManagementFactory.getMemoryMXBean();
MemoryUsage heap = mxBean.getHeapMemoryUsage();
long usedMb = heap.getUsed() / (1024 * 1024);
long committedMb = heap.getCommitted() / (1024 * 1024);
long max = heap.getMax();
String maxText = max < 0 ? "unknown" : (max / (1024 * 1024) + " MB");
return usedMb + " MB used, " + committedMb + " MB committed, max " + maxText;
}
private static void forceGc() throws InterruptedException {
// GC is not guaranteed, but this makes the contrast easier to observe in a demo.
System.gc();
Thread.sleep(200);
System.gc();
Thread.sleep(200);
}
}Follow-up & Tricky Questions:
ThreadLocal leak? I look for pooled threads that live too long and inspect the heap dump for values retained by ThreadLocalMap. The fix is to remove the value in a finally block or use a framework that cleans up after the request.System.gc() a valid test? Only as a rough demo, not as proof. It may reduce noise, but it cannot collect objects that are still strongly referenced.WeakReference prevent leaks? No. It can help caches release entries under memory pressure, but it is not a free fix for bad ownership or unbounded growth.Tricky / gotchas:
Common Mistakes:
Memory Hook: Think: ‘If it is still tied to a string, the GC cannot carry it away.’ The object can be thrown out only when nothing important is still holding it.
Cheat Sheet:
-Xlog:gc* on Java 9+, legacy GC flags on Java 8.ThreadLocal, listeners, queues, classloader leaks.Practice Tasks:
leaky mode and observe how used heap behaves after forced GC.