Memory Leaks and JVM Troubleshooting in Java

Java can still leak: any object the program keeps reachable but no longer needs. Here is how to find and fix them.

What a leak is in a managed language

The collector reclaims unreachable objects perfectly. A Java leak is therefore always the same thing: something is still reachable that the program no longer needs. The collector is doing its job; the reference is the bug.

The common patterns

1. A static collection that only grows

public class Audit {
    private static final List<String> EVENTS = new ArrayList<>();   // never cleared

    public static void record(String event) {
        EVENTS.add(event);
    }
}

A static field is a GC root and lives for the life of the class loader. This is the single most common Java leak.

// A bounded cache instead
private static final Map<String, String> CACHE =
        Collections.synchronizedMap(new LinkedHashMap<>(16, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
                return size() > 1000;
            }
        });

2. Listeners that are never removed

source.addListener(this);       // the source now holds this alive
// ... and nothing ever calls removeListener
try {
    source.addListener(listener);
    doWork();
} finally {
    source.removeListener(listener);
}

3. A mutated key in a hash based collection

Set<MutableKey> set = new HashSet<>();
set.add(key);
key.setName("changed");      // the hash moved
set.remove(key);             // fails, and the entry is unreachable but retained

4. An inner class holding its outer instance

public class Screen {
    class Handler implements Runnable {      // holds a hidden Screen.this
        @Override public void run() { }
    }
}

// A long lived registry holding a Handler keeps the whole Screen alive

Use a static nested class unless the outer instance is genuinely needed.

5. Unclosed resources

// Each open connection or stream holds buffers and native memory
try (var connection = pool.get()) {
    // always closed, on every path
}

6. ThreadLocal in a pooled thread

private static final ThreadLocal<Context> CONTEXT = new ThreadLocal<>();

public void handle(Request request) {
    CONTEXT.set(new Context(request));
    try {
        process();
    } finally {
        CONTEXT.remove();       // essential: the pooled thread outlives the request
    }
}

A pooled thread lives for the life of the application, so anything left in its ThreadLocal is retained indefinitely.

Recognising a leak

Healthy                        Leaking
used                           used
 |    /    /    /            |        /    /
 |   /    /    /             |    / /    /  
 |  /    /    /              | //  /    /
 +---------------------- time   +---------------------- time
 returns to the same floor      the floor keeps rising

Watch the heap immediately after a full collection. If that number trends upwards over hours, something is being retained.

The investigation

1. Confirm from the GC log

java -Xlog:gc*:file=gc.log:time,uptime -jar app.jar
jstat -gc <pid> 5s

2. Take a histogram

jcmd <pid> GC.class_histogram | head -20

 num     #instances         #bytes  class name
   1:       4210338      168413520  com.example.notes.Note
   2:       4210338      101048112  java.util.HashMap$Node

Four million Note objects in a system with a few thousand notes is the answer, or at least the next question.

3. Take a heap dump and find the retainer

jcmd <pid> GC.heap_dump /tmp/heap.hprof
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>

Open the dump in a heap analyser and look for the dominator tree and the path to GC roots of the suspicious class. That path names the exact field holding everything alive, which is the answer you are looking for.

4. Always enable the automatic dump

java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/dumps -jar app.jar

Reading the error messages

MessageWhere to look
Java heap spaceHeap dump, dominator tree
GC overhead limit exceededThe same; the heap is nearly full and collection is thrashing
MetaspaceClass loader count; often repeated redeployment
unable to create native threadThread count and stack size, not the heap
Direct buffer memoryUnreleased NIO direct buffers

Other symptoms and their tools

SymptomTool
The application hangsjstack; look for BLOCKED threads and deadlock reports
High CPUFind the busy thread, match its id to a thread dump
Long pausesGC log; consider a low latency collector
Slow startupClass loading count, static initialisers
Intermittent slownessFlight Recorder over a longer window
java -XX:StartFlightRecording=duration=120s,filename=recording.jfr -jar app.jar
jcmd <pid> JFR.start duration=60s filename=/tmp/recording.jfr

Flight Recorder has very low overhead and records allocation, locking, I/O and GC events together, which makes it the best first tool for anything intermittent.

A worked scenario

Symptom   memory grows over three days, then OutOfMemoryError
Step 1    GC log shows the post collection floor rising steadily
Step 2    histogram shows millions of SessionContext objects
Step 3    heap dump: path to GC roots ends at a static Map in SessionCache
Cause     sessions added on login, never removed on logout or timeout
Fix       evict on logout, and add a time based eviction as a safety net

Prevention

  • Bound every cache, by size or by time.
  • Remove listeners and callbacks in a finally block.
  • Clear every ThreadLocal when the request ends.
  • Use try with resources for anything closeable.
  • Keep static mutable state out of application code.
  • Prefer immutable keys.
  • Watch the post collection heap in monitoring, not just the current usage.

Practice

  1. Write a class that leaks through a static list and watch the heap grow.
  2. Explain why a ThreadLocal leaks in a pooled thread but not in a thread created per task.
  3. Take a class histogram of a running program and interpret the top three entries.
  4. Why is the post collection heap size more informative than current usage?
  5. Given unable to create native thread, explain why increasing the heap will not help.

Conclusion

A Java leak is an unwanted reference, not a collector failure. Confirm it from the GC log, identify the class from a histogram, and find the retaining field through the path to GC roots in a heap dump.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Java notes →

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.