Java Memory Leaks Explained: Causes, Detection and Prevention
Memory leaks in Java are the silent killers of production systems. Your app runs fine in testing, passes QA, gets deployed — and then, sometime around 3 AM on a Tuesday, your on-call phone lights up because the service restarted with an OutOfMemoryError. The heap dump is 4 GB. Good luck explaining that to your team lead. The cruel irony is that Java has a garbage collector specifically to prevent this — yet memory leaks still happen constantly in real-world Java services.
The problem isn't that GC doesn't work. It works brilliantly. The problem is that GC can only collect objects that are unreachable — objects with zero living references pointing at them. A memory leak in Java is always a case of an object that is reachable (something still holds a reference to it) but logically useless (your code will never use it again). The GC can't read your intentions. It only sees the reference graph, and if that graph says an object is reachable, the object stays in memory. Forever, if you're not careful.
By the end of this article you'll be able to identify the six most common leak patterns in Java production code, write defensive code that avoids them from day one, use VisualVM and Eclipse MAT to find leaks in a running JVM, and confidently answer memory leak questions in a senior Java interview. We're going deep — GC internals, WeakReference mechanics, ThreadLocal lifecycle traps, classloader leaks in app servers — the real stuff.
How the JVM Garbage Collector Actually Decides What to Free
Before you can understand why leaks happen, you need a crystal-clear picture of how the GC decides what to keep. The JVM uses a technique called reachability analysis, not reference counting (Python uses reference counting; Java doesn't). The GC starts from a set of root references — local variables on thread stacks, static fields, JNI references — and walks the entire object graph. Any object reachable from a root is considered 'live' and is kept. Everything else is eligible for collection.
This is why a memory leak in Java is always a reference problem, not a GC problem. If you have a static List that accumulates objects, every object in that list is reachable from a GC root (the static field), so nothing gets collected — ever. The GC is doing exactly what it should.
Modern JVMs (G1, ZGC, Shenandoah) split the heap into generations or regions and collect high-churn areas more aggressively. But generational collection doesn't save you from long-lived references. An object that survives a few minor GCs gets promoted to the old generation (tenured space), and old-gen collections are expensive and infrequent. A leak in the old gen will silently grow until you hit an OutOfMemoryError: Java heap space — often hours or days after the leak first started.
import java.util.ArrayList; import java.util.List; /** * Demonstrates the difference between an object being logically * 'done' and being GC-eligible. Run with -Xmx64m to see OOM fast. */ public class ReachabilityDemo { // Static field == GC root. Anything added here NEVER gets collected. private static final List<byte[]> CACHE = new ArrayList<>(); public static void main(String[] args) throws InterruptedException { System.out.println("Simulating a static-field memory leak..."); for (int iteration = 0; iteration < 1000; iteration++) { // Each byte array is 1 MB. We add it and 'forget' about it // logically, but CACHE still holds a reference. byte[] oneMegabyte = new byte[1024 * 1024]; CACHE.add(oneMegabyte); // <-- this line is the leak System.out.printf( "Iteration %d | CACHE size: %d entries | Heap used: %d MB%n", iteration, CACHE.size(), (Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()) / (1024 * 1024) ); Thread.sleep(50); // slow it down so you can watch heap grow } } }
Iteration 0 | CACHE size: 1 entries | Heap used: 3 MB
Iteration 1 | CACHE size: 2 entries | Heap used: 4 MB
Iteration 10 | CACHE size: 11 entries | Heap used: 14 MB
Iteration 50 | CACHE size: 51 entries | Heap used: 54 MB
...
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
The Six Classic Java Memory Leak Patterns (With Real Code)
Every Java memory leak in the wild falls into one of six categories. Knowing them by name means you can spot them in code review in seconds, not hours.
1. Unbounded Static Collections — The example above. A static field grows without a removal strategy.
2. Listener / Observer Not Deregistered — You add an event listener to a button, JVM component, or event bus. When the subscriber is 'done', nobody calls removeListener. The publisher's internal list holds a reference to the subscriber, keeping the entire object graph alive.
3. Non-Static Inner Classes and Anonymous Classes — Every non-static inner class holds an implicit reference to its enclosing outer instance. If you hand that inner class to a long-lived component (a thread pool, a cache), the outer instance is pinned in memory.
4. ThreadLocal Variables in Thread Pools — The most dangerous one in enterprise code. ThreadLocal values are stored in a map on the Thread object itself. In a thread pool, threads live forever. If you set a ThreadLocal and never remove it, that value — and everything it references — lives as long as the thread does.
5. Improper equals/hashCode in HashMap keys — Objects used as HashMap keys that are mutated after insertion can become 'orphaned' in the map: they're in there, taking memory, but unretrievable because their bucket position changed.
6. Classloader Leaks in App Servers — Redeploying a web app creates a new classloader. If any class from the old classloader is referenced by a JVM-wide component (JDBC driver, logging framework, static thread), the entire old classloader and every class it ever loaded stays in memory. This is why Tomcat warns about classloader leaks on undeploy.
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; /** * Demonstrates a ThreadLocal memory leak inside a thread pool. * The thread pool reuses threads, so ThreadLocal values set in one * task are still present when the next task runs on the same thread. * * Run and notice memory climbing. Uncomment the finally block to fix it. */ public class ThreadLocalLeakDemo { // ThreadLocal holds a large payload to make the leak obvious private static final ThreadLocal<byte[]> REQUEST_CONTEXT = new ThreadLocal<>(); public static void main(String[] args) throws InterruptedException { // A fixed thread pool — threads never die, so ThreadLocals accumulate ExecutorService threadPool = Executors.newFixedThreadPool(4); for (int taskNumber = 0; taskNumber < 500; taskNumber++) { final int currentTask = taskNumber; threadPool.submit(() -> { try { // Simulating per-request data (e.g., user session, trace ID) // Each 'request' stores 500 KB of data in ThreadLocal byte[] requestPayload = new byte[500 * 1024]; requestPayload[0] = (byte) currentTask; // use it slightly REQUEST_CONTEXT.set(requestPayload); // ... do the actual work ... processRequest(currentTask); // BUG: we return here without cleaning up. // The thread goes back to the pool, ThreadLocal value stays. } finally { // FIX: always call remove() in a finally block. // Uncomment the line below to prevent the leak: // REQUEST_CONTEXT.remove(); } }); if (currentTask % 50 == 0) { long usedHeapMB = (Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()) / (1024 * 1024); System.out.printf( "Submitted %d tasks | Heap used: ~%d MB%n", currentTask, usedHeapMB ); } } threadPool.shutdown(); threadPool.awaitTermination(30, TimeUnit.SECONDS); } private static void processRequest(int taskNumber) { // Simulate work — read the ThreadLocal value byte[] context = REQUEST_CONTEXT.get(); // In real code this might be a user object, DB connection wrapper, etc. System.out.printf("Task %d processed on thread: %s%n", taskNumber, Thread.currentThread().getName()); } }
Task 0 processed on thread: pool-1-thread-1
Task 1 processed on thread: pool-1-thread-2
...
Submitted 50 tasks | Heap used: ~31 MB
Submitted 100 tasks | Heap used: ~55 MB
Submitted 150 tasks | Heap used: ~79 MB
...
[With REQUEST_CONTEXT.remove() uncommented]
Submitted 50 tasks | Heap used: ~9 MB
Submitted 100 tasks | Heap used: ~9 MB <-- flat! No leak.
WeakReference, SoftReference and the Right Way to Build a Cache
Java gives you four reference strengths — Strong, Soft, Weak, and Phantom — and picking the right one is how you build caches that don't leak.
A Strong reference is your normal Object obj = new Object(). The GC will never collect it while this reference lives.
A SoftReference tells the GC: 'keep this if you can, but if you're about to throw OutOfMemoryError, collect it.' This is ideal for memory-sensitive caches. The JVM guarantees all soft references are cleared before an OOM is thrown.
A WeakReference tells the GC: 'collect this whenever you feel like it — I don't need it to survive a GC cycle.' WeakHashMap uses this internally: if the key has no strong references elsewhere, the entry is automatically removed. This is perfect for metadata caches where the cache entry should live only as long as the key object itself.
A PhantomReference is for post-mortem cleanup — you get notified after the object is finalized but before its memory is reclaimed. Used for native resource cleanup (off-heap memory, file handles) as a safer alternative to finalize().
The most common production mistake is building a cache with a plain HashMap and forgetting an eviction strategy. Use WeakHashMap when the key's lifecycle should drive the cache entry's lifecycle, and use a SoftReference-based cache (or Caffeine/Guava Cache with size bounds and TTL) for everything else.
import java.lang.ref.SoftReference; import java.lang.ref.WeakReference; import java.util.WeakHashMap; /** * Side-by-side comparison of Strong, Soft, and Weak references. * Also shows WeakHashMap auto-eviction in action. * * Run with: java -Xmx32m ReferenceTypesDemo */ public class ReferenceTypesDemo { public static void main(String[] args) throws InterruptedException { demonstrateSoftReference(); demonstrateWeakHashMap(); } private static void demonstrateSoftReference() { System.out.println("=== SoftReference Demo ==="); // Create a large object and wrap it in a SoftReference byte[] largeDataBlob = new byte[10 * 1024 * 1024]; // 10 MB SoftReference<byte[]> softCache = new SoftReference<>(largeDataBlob); // Drop the strong reference — now only the soft ref holds it largeDataBlob = null; byte[] retrieved = softCache.get(); System.out.println("Before memory pressure — data available: " + (retrieved != null)); // true: GC hasn't collected it yet // Simulate memory pressure by allocating a lot try { byte[] memoryHog = new byte[25 * 1024 * 1024]; // forces GC System.out.println("Allocated pressure block: " + memoryHog.length); } catch (OutOfMemoryError oomError) { // JVM clears soft refs before throwing OOM System.out.println("OOM triggered — soft refs were cleared first"); } // After pressure, soft reference MAY have been cleared byte[] afterPressure = softCache.get(); System.out.println("After memory pressure — data available: " + (afterPressure != null)); // likely null now } private static void demonstrateWeakHashMap() throws InterruptedException { System.out.println("\n=== WeakHashMap Auto-Eviction Demo ==="); WeakHashMap<String, String> metadataCache = new WeakHashMap<>(); // IMPORTANT: string literals are interned — they always have a // strong reference from the string pool. Use 'new String()' to // create a key with no other strong references. String sessionKey = new String("user-session-abc123"); metadataCache.put(sessionKey, "{ role: admin, locale: en-US }"); System.out.println("Cache size before GC: " + metadataCache.size()); // 1 // Drop the only strong reference to the key sessionKey = null; // Suggest GC — not guaranteed, but usually runs in demo context System.gc(); Thread.sleep(200); // give GC time to run // WeakHashMap automatically removes entries whose keys were collected System.out.println("Cache size after GC: " + metadataCache.size()); // 0 System.out.println("Entry auto-evicted — no manual cleanup needed!"); } }
Before memory pressure — data available: true
Allocated pressure block: 26214400
After memory pressure — data available: false
=== WeakHashMap Auto-Eviction Demo ===
Cache size before GC: 1
Cache size after GC: 0
Entry auto-evicted — no manual cleanup needed!
Finding Leaks in Production: VisualVM, JVM Flags and Eclipse MAT
Knowing the patterns is half the battle. The other half is diagnosing a leak you didn't write — in a service you've never seen before, under production traffic. Here's the systematic approach.
Step 1: Confirm the leak with GC logs. Enable GC logging on your JVM: -Xlog:gc*:file=gc.log:time,uptime. A healthy heap shows a sawtooth pattern — usage climbs, GC runs, usage drops back to a baseline. A leaking heap shows the baseline creeping upward after each GC cycle. That rising floor is your smoking gun.
Step 2: Get a heap dump. You can trigger one without restarting: jcmd or jmap -dump:format=b,file=/tmp/heapdump.hprof . For automated capture on OOM, add -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp to your JVM flags — you'll always have a dump from the crash.
Step 3: Analyze with Eclipse Memory Analyzer (MAT). Open the .hprof file in MAT and immediately run 'Leak Suspects Report'. MAT will identify the largest retained heaps and show you the reference chain keeping them alive. The 'dominator tree' view tells you which single objects are responsible for retaining the most memory.
Step 4: VisualVM for live profiling. Connect VisualVM to a running JVM, open the Sampler tab, and use 'Memory' sampling to see which classes have the most live instances and total retained bytes. Watch for classes whose instance count grows monotonically — that's your leak class.
/** * This file isn't 'runnable' in the traditional sense — it documents * the exact JVM flags and jcmd commands you need for production leak detection. * * Add these flags to your JVM startup command (e.g., in your Dockerfile, * systemd unit, or Kubernetes deployment's JAVA_OPTS): * * RECOMMENDED JVM FLAGS FOR PRODUCTION LEAK DETECTION: * ===================================================== * * 1. Automatic heap dump on OutOfMemoryError: * -XX:+HeapDumpOnOutOfMemoryError * -XX:HeapDumpPath=/var/log/myapp/heapdumps/ * * 2. GC logging (JDK 9+ unified logging syntax): * -Xlog:gc*:file=/var/log/myapp/gc.log:time,uptime:filecount=5,filesize=20m * * 3. Native memory tracking (for off-heap / metaspace leaks): * -XX:NativeMemoryTracking=summary * * 4. GC algorithm choice — G1 is default in JDK 9+, good for most workloads: * -XX:+UseG1GC * -XX:MaxGCPauseMillis=200 * * LIVE COMMANDS AGAINST A RUNNING JVM: * ===================================== * * # Find the PID of your Java process: * $ jps -l * 18423 com.example.MyService * * # Trigger a heap dump without killing the process: * $ jcmd 18423 GC.heap_dump /tmp/heapdump-$(date +%Y%m%d-%H%M%S).hprof * * # Print class histogram (top memory consumers by class, no full dump): * $ jcmd 18423 GC.class_histogram | head -30 * * # Print native memory summary (catches metaspace and direct buffer leaks): * $ jcmd 18423 VM.native_memory summary * * # Print ThreadLocal info via thread dump (look for long-lived threads * # with unexpectedly large thread-local maps): * $ jcmd 18423 Thread.print > /tmp/threaddump.txt * * INTERPRETING A CLASS HISTOGRAM: * ================================ * num #instances #bytes class name * --- ---------- ------ ---------- * 1: 950,234 22.8MB [B (byte arrays) * 2: 420,000 13.4MB com.example.UserSession * 3: 420,000 6.7MB java.util.HashMap$Node * * If UserSession instance count keeps growing between samples, * and UserSession holds a HashMap (hence the Node count mirrors it), * you almost certainly have a session / cache that never evicts. */ public class LeakDetectionSetup { // This class serves as living documentation. // In your actual project, put these flags in a 'jvm-flags.md' or // your infrastructure-as-code so the team always runs with them. public static void main(String[] args) { System.out.println("JVM flags documented above. Check your startup scripts."); // Print current heap stats at runtime for quick sanity checks: Runtime jvmRuntime = Runtime.getRuntime(); long maxHeapBytes = jvmRuntime.maxMemory(); long totalHeapBytes = jvmRuntime.totalMemory(); long freeHeapBytes = jvmRuntime.freeMemory(); long usedHeapBytes = totalHeapBytes - freeHeapBytes; System.out.printf("Max heap: %6d MB%n", maxHeapBytes / (1024 * 1024)); System.out.printf("Used heap: %6d MB%n", usedHeapBytes / (1024 * 1024)); System.out.printf("Free heap: %6d MB%n", freeHeapBytes / (1024 * 1024)); } }
Max heap: 256 MB
Used heap: 8 MB
Free heap: 248 MB
| Reference Type | Collected When? | Ideal Use Case | get() After GC |
|---|---|---|---|
| Strong Reference (normal) | Never, while ref exists | All regular objects | N/A — always live |
| SoftReference | Only under memory pressure, before OOM | Memory-sensitive caches | Returns null after collection |
| WeakReference | Next GC cycle — no guarantees | WeakHashMap metadata, canonicalization | Returns null after collection |
| PhantomReference | After finalization, before reclaim | Native resource cleanup, off-heap memory | Always returns null — use queue |
| WeakHashMap entry | When key has no strong refs | Cache where entry lifetime == key lifetime | Entry removed automatically |
🎯 Key Takeaways
- A Java memory leak is always a reachability problem, not a GC failure — the GC cannot collect an object that any live reference chain touches, even if your code will never use that object again.
- ThreadLocal in a thread pool is the most dangerous leak pattern in enterprise Java — always call ThreadLocal.remove() in a finally block, or use a framework-level TaskDecorator that does it for you.
- WeakHashMap silently fails to evict entries when you use interned String literals as keys because the string pool holds a permanent strong reference — use 'new String(...)' or a domain object as the key.
- Set -XX:+HeapDumpOnOutOfMemoryError and -XX:HeapDumpPath on every production JVM from day one — without a heap dump from the crash, diagnosing an OOM is almost impossible after the fact.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Forgetting ThreadLocal.remove() in thread pool tasks — Symptom: heap climbs steadily under load; class histogram shows your context/session objects multiplying even when active user count is flat; old-gen fills up with objects tied to request processing — Fix: always wrap ThreadLocal.set() in a try/finally block with REQUEST_CONTEXT.remove() in the finally clause. In Spring, implement TaskDecorator and register it on your ThreadPoolTaskExecutor to handle cleanup automatically across all async tasks.
- ✕Mistake 2: Using a String literal as a WeakHashMap key — Symptom: the WeakHashMap never shrinks, entries accumulate forever, memory grows unboundedly — Fix: String literals are interned by the JVM and held in the string pool, which acts as a permanent strong reference. The weak key is never collected. Always use 'new String(key)' or a proper domain object as the key when you need WeakHashMap's auto-eviction behaviour. Better: use Caffeine or Guava Cache with explicit size bounds instead of WeakHashMap for anything non-trivial.
- ✕Mistake 3: Registering listeners / observers on a long-lived publisher and never deregistering — Symptom: object graph shows the publisher (EventBus, JMX MBeanServer, Swing component) holding thousands of stale subscriber instances; heap dump reveals subscriber objects whose 'owner' screens/services were long since closed or reloaded — Fix: always implement a cleanup/destroy lifecycle method that calls publisher.removeListener(this) or eventBus.unregister(this). In Spring, use @EventListener on a managed bean (Spring handles registration lifetime) or implement DisposableBean to deregister in destroy().
Interview Questions on This Topic
- QThe GC is supposed to handle memory management in Java — so how can a memory leak even occur? Walk me through the exact mechanism that keeps an object alive despite it being logically unused.
- QYou get paged at 2 AM: production service restarted with OutOfMemoryError. You have a heap dump. Walk me through exactly what you do next to identify the leak — tools, commands, what you're looking at, and how you pinpoint the root cause.
- QWhat is the difference between a SoftReference and a WeakReference, and when would you choose one over the other? What happens if you use a String literal as a key in a WeakHashMap, and why doesn't the entry get evicted?
Frequently Asked Questions
How do I find a memory leak in a Java application without restarting it?
Use 'jcmd
Does setting an object to null in Java immediately free its memory?
No. Setting a reference to null removes that particular reference from the reachability graph, but the object is only eligible for collection once ALL references to it are gone. The actual memory reclaim happens asynchronously when the GC runs — you have no control over exactly when. In most cases you don't need to null out references explicitly; just let variables go out of scope naturally.
What is the difference between a memory leak and an OutOfMemoryError? Are they the same thing?
A memory leak is the cause; OutOfMemoryError is one possible symptom. A memory leak means your application holds references to objects it will never use again, preventing GC. An OOM is thrown when the JVM cannot allocate a new object after exhausting heap and running a full GC. You can get an OOM without a leak (e.g., processing a genuinely huge dataset) and you can have a slow leak that runs for days before triggering an OOM. Always check for a rising heap baseline after GC cycles — that pattern confirms a leak specifically.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.