Memory Leaks in Java

Naveen AnamaneniUpdated · 6 min read

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

CauseExample
Collections that only growA static Map used as a cache with no limit or removal
Listeners never removedAdding event listeners and never unregistering them
ThreadLocal values in poolsValues never remove()d stay with long-lived threads
Unclosed resourcesStreams, connections and files not closed
Inner classes holding the outer objectA long-lived anonymous class or lambda capturing a large object
Wrong equals/hashCode on keysEqual 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.

LeakHunt.java▶ Try it
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"));
    }
}
Output
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 +22k

Explanation: 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:

Terminal
jps -l
jmap -histo:live <process-id> | head
jcmd <process-id> GC.heap_dump /tmp/server.hprof
Terminal output
 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:

FixedServer.java▶ Try it
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 + ")");
    }
}
Output
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

  1. 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.
  2. Harder: Add an unregister method, call it when each object is done, and show that the instance count no longer grows.

Quick check: Memory leaks

4 questions
1What is a memory leak in Java?
2Which is a common cause?
3What does a heap dump's "path to GC roots" show?
4Which option saves a heap dump on OutOfMemoryError?

Key Takeaways

Was this tutorial helpful?

Lesson 445 of 482