The JVM’s garbage collector is failing to reclaim memory because an object that should no longer be referenced is still being held onto by an unexpected part of the application.

Common Causes and Fixes

1. Static Collections Holding Unwanted References

  • Diagnosis: Run jmap -histo:live <pid> and look for large collections (e.g., ArrayList, HashMap) that are consistently growing and hold objects that should have been released. Then, use jhat <hprof_file> or a profiler like YourKit/JProfiler to analyze the heap dump and trace the references to these objects. Look for static fields pointing to these collections.
  • Fix:
    // Before:
    public class Cache {
        private static List<MyObject> cacheList = new ArrayList<>();
    
        public static void addObject(MyObject obj) {
            cacheList.add(obj);
        }
        // ... other methods
    }
    
    // After:
    public class Cache {
        private List<MyObject> cacheList = new ArrayList<>(); // Make it instance-based
    
        public void addObject(MyObject obj) {
            cacheList.add(obj);
        }
    
        public void clearCache() { // Add a method to explicitly clear if needed
            cacheList.clear();
        }
        // ... other methods
    }
    
  • Why it works: Static collections are shared across all instances of a class and live for the entire duration of the JVM. If objects are added to a static collection and never explicitly removed, they will never be garbage collected, even if the "user" of the object no longer needs it. Making the collection instance-based or adding explicit removal/clearing logic ensures objects can be reclaimed when the instance is no longer needed or when the cache is intentionally cleared.

2. ThreadLocals Not Being Cleaned Up

  • Diagnosis: Use a heap dump analysis tool. Search for ThreadLocalMap entries that contain references to objects that should have been garbage collected. Look for Thread objects that are still alive but their associated ThreadLocalMap holds onto large or long-lived objects.

  • Fix: Always call threadLocalVariable.remove() in a finally block after you are finished with the ThreadLocal variable.

    private static final ThreadLocal<MyObject> myObjectHolder = new ThreadLocal<>();
    
    public void processRequest(Request request) {
        try {
            MyObject obj = createMyObject(request);
            myObjectHolder.set(obj);
            // ... do work with obj ...
        } finally {
            myObjectHolder.remove(); // Crucial for preventing leaks
        }
    }
    
  • Why it works: When a thread is managed by a thread pool (common in web servers like Tomcat, Jetty), the thread is reused. If a ThreadLocal value is set and not removed, the object referenced by the ThreadLocal will remain attached to that thread’s ThreadLocalMap even after the request processing is complete. This prevents the object from being garbage collected. Explicitly calling remove() clears the reference from the ThreadLocalMap.

3. Unclosed Resources (Streams, Connections, etc.)

  • Diagnosis: Heap dump analysis showing many instances of stream objects, database connections, or other resource wrappers that are still open and holding onto underlying system resources or large buffers. Look for objects like FileInputStream, Socket, PreparedStatement, or custom resource wrappers that are not being closed.

  • Fix: Use try-with-resources for all AutoCloseable resources.

    // Before (prone to leaks if exceptions occur before close()):
    Reader reader = null;
    try {
        reader = new FileReader("myfile.txt");
        // ... read from reader ...
    } finally {
        if (reader != null) {
            try {
                reader.close();
            } catch (IOException e) {
                // handle close exception
            }
        }
    }
    
    // After (safe and concise):
    try (Reader reader = new FileReader("myfile.txt")) {
        // ... read from reader ...
    } catch (IOException e) {
        // handle read or close exception
    }
    
  • Why it works: The try-with-resources statement ensures that close() is called on each resource declared in the try statement, even if an exception occurs. This guarantees that resources are properly released, preventing leaks of file handles, network sockets, database connections, and associated memory buffers.

4. ClassLoader Leaks

  • Diagnosis: This is often seen in application servers where applications are hot-deployed or redeployed. Heap dumps show a large number of classes loaded by a ClassLoader that should have been discarded when the application was undeployed. Common culprits are static fields within classes loaded by that ClassLoader, or ThreadLocal variables still referencing objects from that ClassLoader.
  • Fix:
    • Application Servers: Ensure your application server’s undeployment process correctly tears down application classloaders. Check for custom ClassLoader implementations that might not be properly disposing of resources or references.
    • General: Review static fields and ThreadLocal usage. If a class is loaded by an application-specific ClassLoader, any static fields in that class will keep the class (and thus the ClassLoader) alive. Look for libraries that might cache instances or configurations using static fields tied to application-specific objects.
    // Example of a problematic pattern to avoid:
    public class AppSpecificUtil {
        private static final Map<String, Object> CACHE = new HashMap<>(); // Static cache
    
        public static void put(String key, Object value) {
            CACHE.put(key, value); // If 'value' is from an app-specific class, it keeps the class alive
        }
        // ...
    }
    
  • Why it works: A ClassLoader is garbage collected only when no objects are referencing any classes it loaded. If an application is undeployed, its ClassLoader should also be eligible for garbage collection. Leaks occur when static references, ThreadLocals, or other long-lived objects (often managed by the container itself or other persistent parts of the system) continue to hold references to classes loaded by the application’s ClassLoader.

5. Unnecessary Object Caching/Serialization

  • Diagnosis: Heap dump analysis reveals a large number of objects that are instances of configuration objects, parsed XML/JSON data, or other data structures that are created once and then cached indefinitely in memory. Look for patterns where objects are created, then stored in a Map or List with a key that might never be removed or invalidated.

  • Fix: Implement an eviction policy for your caches (e.g., LRU - Least Recently Used, time-based expiration) or explicitly clear caches when they are no longer needed. Consider using libraries like Guava’s Cache or Caffeine for robust caching solutions.

    // Using Guava Cache:
    import com.google.common.cache.Cache;
    import com.google.common.cache.CacheBuilder;
    import java.util.concurrent.TimeUnit;
    
    public class ConfigCache {
        private final Cache<String, AppConfig> configCache = CacheBuilder.newBuilder()
            .expireAfterWrite(10, TimeUnit.MINUTES) // Evict after 10 minutes
            .maximumSize(1000) // Limit to 1000 entries
            .build();
    
        public AppConfig getConfig(String key) {
            return configCache.get(key, () -> loadConfigFromSource(key));
        }
    
        private AppConfig loadConfigFromSource(String key) {
            // ... logic to load config ...
            return new AppConfig(key);
        }
    }
    
  • Why it works: Caching is essential for performance, but unbounded caches will eventually consume all available memory. Implementing eviction policies ensures that older or less-used items are automatically removed, allowing them to be garbage collected and preventing memory leaks.

6. Finalizers

  • Diagnosis: Objects with finalize() methods can cause memory leaks because the garbage collector needs to perform a two-step process: first, it identifies objects that are no longer reachable; second, it schedules them for finalization. If the finalize() method itself creates a new reference to the object (a "resurrection"), the object will not be garbage collected in the next cycle, and this can lead to a persistent leak if it happens repeatedly. Also, objects waiting for finalization are held longer in memory.

  • Fix: Avoid using finalize() methods. If you must clean up resources, use try-with-resources or explicit close() methods.

    // Avoid this pattern:
    public class RiskyResource {
        private Object resource;
    
        public RiskyResource() {
            this.resource = new Object(); // Imagine this is a large native resource
        }
    
        @Override
        protected void finalize() throws Throwable {
            try {
                // Cleanup logic here - problematic if it resurrects 'this'
                System.out.println("Finalizing RiskyResource");
                // DON'T DO THIS: this.resource = null; // Still not enough if 'this' is referenced elsewhere
                // DON'T DO THIS EVER: someStaticList.add(this); // Resurrection!
            } finally {
                super.finalize(); // Always call super.finalize()
            }
        }
    }
    
  • Why it works: Finalizers introduce unpredictable delays and complexity. Objects are only eligible for garbage collection after their finalizer has run, and if the finalizer resurrects the object, it delays collection indefinitely. Modern Java practices strongly recommend against finalizers, favoring explicit resource management patterns.

The next error you’ll likely encounter after fixing these JVM memory leaks is a java.lang.OutOfMemoryError: Metaspace if you’ve also been loading and unloading many classes without proper cleanup, or a persistent slowdown due to excessive garbage collection activity.

Want structured learning?

Take the full Jvm course →