Skip to main content
โšก Calmops

Java Memory Leaks Explained: Causes, Detection, and Prevention

Introduction

Java developers often assume that because the language has an automatic Garbage Collector (GC), they never need to worry about memory management. While it is true that you do not manually allocate and free memory (like malloc and free in C/C++), Java is still highly susceptible to Memory Leaks.

In Java, a memory leak occurs when the application no longer needs an object, but the Garbage Collector cannot remove it from memory because some other part of the application still holds a strong reference to it. Over time, these unreachable but referenced objects accumulate, consuming the available heap space until the JVM inevitably throws a java.lang.OutOfMemoryError: Java heap space.

This article dives deep into how memory leaks happen in Java, the most common anti-patterns that cause them, and the tools you can use to detect and resolve them in production 2026 application stacks.


How the Java Garbage Collector Works

To understand leaks, you must first understand how Java determines what is safe to clean up.

The JVM Garbage Collector uses a Reachability (Mark-and-Sweep) algorithm. It starts from a set of “GC Roots” (like local variables in active threads, active threads themselves, static fields, and JNI references) and traverses the object graph.

  1. Mark: Every object it can reach from the GC Roots is marked as “alive”.
  2. Sweep: Any object in the heap that is not marked is considered unreachable and its memory is subsequently reclaimed.

A memory leak happens when an object is virtually “dead” (the business logic will never use it again) but remains structurally “reachable” from a GC Root.


5 Common Causes of Java Memory Leaks

Let’s examine the most frequent architectural mistakes that lead to memory leaks, complete with code examples.

1. Static Collections and Caching

Static fields belong to the Class object, and Class objects are tied to the ClassLoader. Therefore, static fields remain in memory for the entire lifecycle of the JVM (unless the ClassLoader itself is collected, which is rare). If you use a static collection to cache data without a strict eviction policy, it will grow infinitely.

// BAD: This list will grow indefinitely and never be garbage collected.
public class UserCache {
    private static final List<User> cache = new ArrayList<>();

    public static void cacheUser(User user) {
        cache.add(user);
    }
}

The Fix: Use a bounded cache library like Caffeine or Guava, or utilize WeakHashMap if you want the GC to clear entries when they are no longer referenced elsewhere.

// GOOD: Using Caffeine for bounded caching with size and time eviction
Cache<String, User> userCache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build();

2. Unclosed Resources and Streams

Whenever you open a connection to a database, a network socket, or a file stream, the JVM allocates memory and OS resources to maintain it. If you forget to close these streams, the resources linger until the GC invokes the finalize() method (which is deprecated in modern Java and highly unreliable).

// BAD: The connection might leak if an exception is thrown
public void readFile() throws IOException {
    FileInputStream fis = new FileInputStream("data.txt");
    int data = fis.read();
    // If read() throws an exception, fis.close() is never reached!
    fis.close(); 
}

The Fix: Always use the try-with-resources block introduced in Java 7. It guarantees that the resource will be closed automatically, even if an exception occurs.

// GOOD: try-with-resources ensures the stream is closed
public void readFile() throws IOException {
    try (FileInputStream fis = new FileInputStream("data.txt")) {
        int data = fis.read();
    } // fis is implicitly closed here
}

3. ThreadLocal Variable Leaks

ThreadLocal is a powerful construct that allows you to store variables accessible only by a specific thread. However, in modern web servers (like Tomcat, Jetty, or Spring Boot embedded servers), threads are pooled. A thread handles a request, finishes, and goes back to the pool to handle the next request.

If you do not clean up your ThreadLocal variables, the thread retains the data for the next request, causing both security context bleed and memory leaks.

public class UserContext {
    private static final ThreadLocal<User> currentUser = new ThreadLocal<>();

    public static void set(User user) {
        currentUser.set(user);
    }
    
    // BAD: If remove() is never called, the pooled thread keeps the User object forever.
    public static void remove() {
        currentUser.remove();
    }
}

The Fix: Always use a try-finally block to ensure ThreadLocal.remove() is called at the end of the request lifecycle, often best handled in an Interceptor or Filter.

// In a Spring HandlerInterceptor or generic Web Filter
try {
    UserContext.set(authenticatedUser);
    chain.doFilter(request, response);
} finally {
    UserContext.remove(); // Crucial!
}

4. Inner Classes and Anonymous Classes

Non-static inner classes and anonymous classes hold an implicit reference to their enclosing outer class instance. If you pass an instance of an anonymous class (like a listener or callback) to a long-lived object, the outer class will also be kept alive in memory.

public class ActivityView {
    private byte[] hugeImage = new byte[10_000_000]; // 10MB of data

    public void registerListener(EventPublisher publisher) {
        // BAD: Anonymous class holds a hidden reference to the ActivityView instance
        publisher.onEvent(new EventListener() {
            @Override
            public void onTrigger() {
                System.out.println("Event fired!");
            }
        });
    }
}

The Fix: Make the inner class static, or use a lambda expression that does not capture this. Static inner classes do not hold references to outer classes.

5. Improper equals() and hashCode() in Maps

If you use a custom object as a key in a HashMap or HashSet and do not properly override the equals() and hashCode() methods, the HashMap will not be able to find the key later. You will end up adding duplicate objects continuously because the Map thinks they are different.

public class CustomKey {
    private String name;
    
    public CustomKey(String name) { this.name = name; }
    // BAD: Missing equals() and hashCode()
}

Map<CustomKey, String> map = new HashMap<>();
map.put(new CustomKey("Alice"), "Data");

// Because hashCode() is not overridden, this creates a new entry instead of overwriting!
map.put(new CustomKey("Alice"), "New Data"); 

6. Metaspace (Formerly PermGen) ClassLoader Leaks

While most memory leaks occur in the Heap, there is another critical area of JVM memory that routinely leaks in enterprise applications: Metaspace (introduced in Java 8 to replace Permanent Generation / PermGen).

Metaspace stores class definitions, method metadata, and the runtime constant pool. It is not part of the standard heap and is allocated out of native OS memory.

A ClassLoader leak occurs frequently in hot-reloading environments (like Tomcat, WildFly, or OSGi containers). When you redeploy an application .war file, the app server drops the old ClassLoader and creates a new one for the new code. However, if a thread from the container pool retains a reference to an object loaded by the old ClassLoader, the entire old ClassLoader framework (and every class definition it ever loaded) remains pinned in Metaspace.

Symptoms of a Metaspace Leak: You will eventually see a java.lang.OutOfMemoryError: Metaspace rather than Java heap space.

public class LeakingTask implements Runnable {
    // BAD: The thread pool keeps this object alive, which keeps its ClassLoader alive.
    public void run() {
        System.out.println("Processing background task...");
    }
}

The Fix: When shutting down or undeploying a module, you must forcefully shut down any background threads, unregister JDBC drivers, and stop any custom TimerTasks that were started by your web application.


Tools for Detecting Java Memory Leaks

When an OutOfMemoryError occurs in production, you need objective profiling data to identify the culprit.

1. Generating Heap Dumps

A heap dump is a snapshot of the JVM memory at a specific point in time. You can instruct the JVM to automatically generate a heap dump when it crashes by adding this JVM flag on startup:

java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/java/heapdump.hprof -jar app.jar

Alternatively, you can trigger a live heap dump using jmap (part of the JDK):

jmap -dump:live,format=b,file=heap_snapshot.hprof <PID>

Note: Generating a heap dump pauses the JVM and can take several seconds. Be careful doing this on high-traffic production nodes.

2. Eclipse Memory Analyzer Tool (MAT)

Once you have the .hprof file, open it in Eclipse MAT. MAT parses the massive dump file and provides a “Leak Suspects Report”. It automatically calculates the “Retained Size” of objects and builds a dominator tree, pinpointing exactly which class is holding onto 90% of your RAM.

3. VisualVM and Java Mission Control (JMC)

For local development and monitoring, JDKs include VisualVM or JMC. These tools let you attach to a running JVM process to view real-time charts of CPU usage, GC activity, and heap consumption. If you see the heap usage graph forming a “sawtooth” pattern that consistently climbs higher after every GC cycle without ever returning to its baseline, you have a memory leak.


Summary and Best Practices

Memory leaks in Java are almost exclusively caused by dangling references. To keep your JVM healthy:

  1. Beware of Static Maps: Never use HashMap or ArrayList as global static caches without eviction logic.
  2. Clean up ThreadLocals: Always remove ThreadLocal values in a finally block when running in thread-pooled environments like Spring web nodes.
  3. Always close Resources: Cultivate the habit of using try-with-resources.
  4. Profile Early: Don’t wait for production to crash. Run VisualVM against your application locally during load testing.
  5. Implement equals() & hashCode(): Be extremely careful when using custom objects as Map keys.

Resources

Comments