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 common patterns
- 1. A static collection that only grows
- 2. Listeners that are never removed
- 3. A mutated key in a hash based collection
- 4. An inner class holding its outer instance
- 5. Unclosed resources
- 6. ThreadLocal in a pooled thread
- Recognising a leak
- The investigation
- 1. Confirm from the GC log
- 2. Take a histogram
- 3. Take a heap dump and find the retainer
- 4. Always enable the automatic dump
- Reading the error messages
- Other symptoms and their tools
- A worked scenario
- Prevention
- Practice
- Conclusion
-
Java Basics
- Introduction to Java
- Setting Up Java and Writing Your First Program
- Variables, Data Types and Literals in Java
- Type Casting and Type Conversion in Java
- Operators and Expressions in Java
- Input and Output in Java
- Comments, Keywords and Naming Conventions in Java
- Control Flow in Java: if, else and switch
- Loops in Java: for, while and do-while
- Methods
- Arrays and Strings
-
OOP
- Classes and Objects in Java
- Constructors in Java
- The this Keyword in Java
- The static Keyword in Java
- Encapsulation in Java
- Access Modifiers in Java
- Inheritance in Java
- Method Overriding and super in Java
- Polymorphism in Java
- Abstraction, Abstract Classes and Interfaces in Java
- Composition, Aggregation and Association in Java
- The Object Lifecycle in Java
- Core Java
- Exception Handling
-
Collections
- The Java Collections Framework
- List in Java: ArrayList, LinkedList, Vector and Stack
- Set in Java: HashSet, LinkedHashSet and TreeSet
- Map in Java: HashMap, LinkedHashMap and TreeMap
- How HashMap Works Internally in Java
- Queue and Deque in Java: ArrayDeque and PriorityQueue
- Iterators in Java
- Comparable and Comparator in Java
- Collections Utilities and Choosing the Right Collection
- Generics
- Java 8+
- Stream API
- Date and Time
- File and I/O
-
Multithreading
- Threads in Java: Processes, Runnable and Thread
- Thread Lifecycle in Java
- Synchronization in Java: synchronized and volatile
- Locks and Atomic Classes in Java
- Race Conditions and Deadlocks in Java
- The Executor Framework and Thread Pools in Java
- Future and CompletableFuture in Java
- Concurrent Collections in Java
- The Java Memory Model
- JVM and Memory
- Advanced Java
- Networking
- JDBC
- Testing
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 removeListenertry {
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 retained4. 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 risingWatch 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> 5s2. 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$NodeFour 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.jarReading the error messages
| Message | Where to look |
|---|---|
Java heap space | Heap dump, dominator tree |
GC overhead limit exceeded | The same; the heap is nearly full and collection is thrashing |
Metaspace | Class loader count; often repeated redeployment |
unable to create native thread | Thread count and stack size, not the heap |
Direct buffer memory | Unreleased NIO direct buffers |
Other symptoms and their tools
| Symptom | Tool |
|---|---|
| The application hangs | jstack; look for BLOCKED threads and deadlock reports |
| High CPU | Find the busy thread, match its id to a thread dump |
| Long pauses | GC log; consider a low latency collector |
| Slow startup | Class loading count, static initialisers |
| Intermittent slowness | Flight 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.jfrFlight 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 netPrevention
- Bound every cache, by size or by time.
- Remove listeners and callbacks in a
finallyblock. - Clear every
ThreadLocalwhen 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
- Write a class that leaks through a static list and watch the heap grow.
- Explain why a
ThreadLocalleaks in a pooled thread but not in a thread created per task. - Take a class histogram of a running program and interpret the top three entries.
- Why is the post collection heap size more informative than current usage?
- 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.