Java Memory Leaks: Common Causes and How to Detect Them

Backend2026-09-27TryQuickToolBox

Your Java application starts fast but over hours or days it slows to a crawl, throws OutOfMemoryError, or gets killed by the container. You restart it, and the cycle repeats. That's the classic signature of a Java memory leak: objects that are no longer needed but remain reachable, so the garbage collector can't reclaim them. Unlike C/C++, Java's GC handles most memory automatically, but leaks still happen when references are unintentionally held. This article explains the most common causes and gives you a practical workflow to detect and fix them.

What Is a Java Memory Leak?

A memory leak occurs when objects are no longer used by the application but are still referenced, preventing garbage collection. Over time, heap usage grows, GC runs more frequently, and eventually the JVM throws OutOfMemoryError: Java heap space. Leaks can be small and slow (a few KB per request) or large and fast (caching entire result sets).

Common Causes of Java Memory Leaks

1. Static Collections That Grow Forever

Static fields live for the lifetime of the JVM. A static Map used as a cache without eviction is a classic leak. For example:

public class UserCache {
    private static final Map<Long, User> CACHE = new HashMap<>();

    public static void put(Long id, User user) {
        CACHE.put(id, user); // never removed
    }
}

Every user added stays forever. Fix: use a bounded cache like Caffeine or Guava with size limits and expiration.

2. Unclosed Resources

Streams, connections, and readers must be closed. If you forget, native resources and their Java wrappers leak. Always use try-with-resources:

try (InputStream in = new FileInputStream(file)) {
    // use in
} // automatically closed

This also applies to database connections, HTTP clients, and thread pools.

3. Listeners and Callbacks Not Removed

When you register a listener (e.g., addListener) but never remove it, the publisher holds a reference to the listener, which may hold a reference to your entire object graph. This is common in GUIs, event buses, and long-lived contexts.

4. ThreadLocal Variables

ThreadLocal values are stored in the thread's ThreadLocalMap. In thread pools, threads are reused, so if you don't call remove(), the value stays attached to the thread and leaks. Always clean up:

try {
    threadLocal.set(context);
    // work
} finally {
    threadLocal.remove();
}

5. Inner Classes Holding Outer References

Non-static inner classes (including anonymous classes) hold an implicit reference to the outer instance. If an inner class instance outlives the outer object (e.g., stored in a static list), the outer object cannot be collected. Make inner classes static when they don't need the outer instance.

6. Improper equals() and hashCode()

If you use objects as keys in a HashMap but override equals() without hashCode() (or vice versa), lookups fail and entries accumulate. Always override both consistently.

7. String Interning and Substrings

Before Java 7, String.substring() shared the original char array, so a small substring could hold a large array. Modern Java copies, but interning many unique strings (e.g., String.intern()) can still fill the string pool.

How to Detect Java Memory Leaks

Detection follows a systematic process. Here's a step-by-step approach:

  1. Monitor heap usage over time. Use JVisualVM, JConsole, or your APM. A healthy app shows a sawtooth pattern: heap grows, GC reclaims, heap drops. A leak shows a rising baseline after each GC.
  2. Enable GC logging. Add -Xlog:gc*:file=gc.log:time,uptime,level,tags (Java 9+) or -XX:+PrintGCDetails (Java 8). Analyze logs with tools like GCeasy to see if old gen keeps growing.
  3. Capture a heap dump. When heap is high, run jmap -dump:live,format=b,file=heap.hprof <pid> or use jcmd <pid> GC.heap_dump heap.hprof. You can also trigger a dump on OutOfMemoryError with -XX:+HeapDumpOnOutOfMemoryError.
  4. Analyze the heap dump. Open it in Eclipse MAT or VisualVM. Look for the dominator tree to find objects retaining the most memory. Check for suspicious collections with large retained sizes.
  5. Take a second dump and compare. If the same objects grow between dumps, you've found the leak.
  6. Use a profiler for live analysis. Tools like YourKit, JProfiler, or async-profiler can track allocation sites and reference chains without stopping the app.

Here's a quick comparison of common tools:

ToolTypeBest For
Eclipse MATHeap dump analyzerFinding dominators and leak suspects
VisualVMMonitoring + heap dumpQuick live checks and basic analysis
JProfiler / YourKitProfilerAllocation tracking and reference chains
async-profilerLow-overhead profilerProduction profiling with flame graphs

Fixing and Preventing Leaks

Once you identify the leak, fix it by removing the unintended reference. Common fixes:

Prevention is better than cure. Add memory monitoring to your CI/CD pipeline, run load tests with heap checks, and set -Xmx appropriately. Use static analysis tools like SpotBugs to catch common patterns.

FAQ

What is the difference between a memory leak and a memory spike?

A memory spike is a temporary increase in heap usage that GC reclaims. A leak is a steady increase over time because objects remain reachable and cannot be collected.

Can a Java memory leak cause OutOfMemoryError even if the heap is large?

Yes. If the leak rate exceeds the GC's ability to reclaim, the heap will eventually fill regardless of size. Increasing heap only delays the failure.

How do I find a memory leak in production without downtime?

Use low-overhead tools like async-profiler or JFR (Java Flight Recorder) to record allocations and heap statistics. You can also trigger a heap dump with jcmd while the app runs, though it may pause briefly.

Need to analyze GC logs or other text-based logs? Try our Nginx Log Analyzer to parse and visualize log patterns quickly.