Memory Leaks in Java
A memory leak in Java happens when a program keeps references to objects it no longer needs. The garbage collector can only free unreachable objects, so anything still referenced, even by mistake, stays in memory forever. The heap slowly fills, garbage collections become more frequent and longer, and eventually the program fails with OutOfMemoryError. In this lesson, you will learn the common causes of leaks, and then create one on purpose, find it with class histograms and a heap dump, and fix it.
Common Causes
| Cause | Example |
|---|---|
| Collections that only grow | A static Map used as a cache with no limit or removal |
| Listeners never removed | Adding event listeners and never unregistering them |
ThreadLocal values in pools | Values never remove()d stay with long-lived threads |
| Unclosed resources | Streams, connections and files not closed |
| Inner classes holding the outer object | A long-lived anonymous class or lambda capturing a large object |
Wrong equals/hashCode on keys | Equal keys don't match, so maps keep adding new entries |
The Checkpoint: Finding and Fixing a Leak
Step 1: A Leaky Program
This server stores every session in a static map and never removes it. To find what grows, it takes the JVM's class histogram (the same data as jcmd <pid> GC.class_histogram) before and after two batches of 10,000 requests, and prints the classes whose instance count grew by about 10,000 each time.
import java.lang.management.ManagementFactory;
import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;
import javax.management.ObjectName;
public class LeakHunt {
record Session(String user, byte[] data) { }
static final Map<Integer, Session> SESSIONS = new HashMap<>();
static void handleRequest(int id) {
SESSIONS.put(id, new Session("user-" + id, new byte[1_000]));
}
static Map<String, Long> histogram() throws Exception {
String text = (String) ManagementFactory.getPlatformMBeanServer()
.invoke(new ObjectName("com.sun.management:type=DiagnosticCommand"),
"gcClassHistogram", new Object[] {null},
new String[] {String[].class.getName()});
Map<String, Long> counts = new HashMap<>();
for (String line : text.split("\n")) {
String[] p = line.trim().split("\\s+");
if (p.length >= 4 && p[0].endsWith(":")) {
counts.put(p[3], Long.parseLong(p[1]));
}
}
return counts;
}
public static void main(String[] args) throws Exception {
Map<String, Long> first = histogram();
for (int id = 0; id < 10_000; id++) handleRequest(id);
Map<String, Long> second = histogram();
for (int id = 10_000; id < 20_000; id++) handleRequest(id);
Map<String, Long> third = histogram();
Map<String, Long> growth = new TreeMap<>();
for (String cls : third.keySet()) {
long a = first.getOrDefault(cls, 0L);
long b = second.getOrDefault(cls, 0L);
long c = third.get(cls);
if (b - a >= 9_000 && c - b >= 9_000) {
growth.put(cls, c - a);
}
}
System.out.println("Classes that grew by about 10,000 per batch:");
growth.forEach((cls, n) ->
System.out.println(" " + cls + " +" + (n / 1000) + "k"));
}
}Classes that grew by about 10,000 per batch:
LeakHunt$Session +20k
[B +42k
java.lang.Integer +19k
java.lang.String +22k
java.util.HashMap$Node +22kExplanation: The histogram is taken with a garbage collection first, so only live objects are counted. Every batch of requests leaves about 10,000 more Session objects, HashMap$Node entries, String user names, Integer keys and byte[] arrays ([B) alive, and they never go away. HashMap$Node growing together with Session points straight at a map that keeps sessions.
Step 2: Confirming with a Heap Dump
On a real server, you take the same evidence from outside the process. A heap dump is a file containing every live object, which tools such as VisualVM or Eclipse MAT can open:
jps -l
jmap -histo:live <process-id> | head
jcmd <process-id> GC.heap_dump /tmp/server.hprof num #instances #bytes class name (module)
-------------------------------------------------------
1: 50000 1200000 Busy$Order
2: 1076 420368 [Ljava.lang.Object; (java.base@25.0.5.0.3)
3: 8531 383912 [B (java.base@25.0.5.0.3)
...
Dumping heap to /tmp/server.hprof ...
Heap dump file created [6350847 bytes in 0.009 secs](This output is from a small program holding 50,000 Order records; the dump path is shortened.) In VisualVM or MAT, open the dump, sort classes by retained size, and choose "Path to GC roots" on one of the suspicious objects. For the leak above, the path ends at the static field LeakHunt.SESSIONS, which is the leak's cause.
Step 3: The Fix
Sessions must be removed when they end, and an in-memory cache needs a size limit:
import java.util.LinkedHashMap;
import java.util.Map;
public class FixedServer {
record Session(String user, byte[] data) { }
static final int MAX_SESSIONS = 1_000;
static final Map<Integer, Session> SESSIONS =
new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Session> e) {
return size() > MAX_SESSIONS;
}
};
static void handleRequest(int id) {
SESSIONS.put(id, new Session("user-" + id, new byte[1_000]));
}
static void logout(int id) {
SESSIONS.remove(id);
}
public static void main(String[] args) {
for (int id = 0; id < 20_000; id++) {
handleRequest(id);
if (id % 2 == 0) {
logout(id);
}
}
System.out.println("Sessions held: " + SESSIONS.size()
+ " (never more than " + MAX_SESSIONS + ")");
}
}Sessions held: 1000 (never more than 1000)Explanation: Logging out removes a session, and the map is a size-limited LRU cache (see "LinkedHashMap in Java"), so even sessions that are never closed can't grow without limit. After 20,000 requests, memory holds at most 1,000 sessions instead of 20,000.
Keep the Evidence
Common Mistakes
Using a static HashMap as an unbounded cache. Add a size limit or use a caching library.
Fixing a leak by raising -Xmx. It only delays the crash.
Looking at one histogram. Compare several snapshots over time to see what keeps growing.
Interview Corner
How can a memory leak happen in Java, and how do you find it?
Leaks happen when objects that are no longer needed remain reachable, typically through static collections or caches that only grow, listeners that are never removed, ThreadLocal values left in pooled threads, or keys with broken equals/hashCode. Symptoms are a steadily rising heap after each GC and eventually OutOfMemoryError. To find one, compare class histograms over time (jmap -histo:live or jcmd GC.class_histogram), take a heap dump (jcmd GC.heap_dump or -XX:+HeapDumpOnOutOfMemoryError), and analyze it in Eclipse MAT or VisualVM, following the path to GC roots of the suspicious objects. Java Flight Recorder's old-object sampling can also help.
Practice
- Easy: Write a class that adds a listener to a static list in its constructor and never removes it. Create 10,000 instances and show with a histogram that none are collected.
- Harder: Add an
unregistermethod, call it when each object is done, and show that the instance count no longer grows.
Key Takeaways
Was this tutorial helpful?