☕ Java

Memory Leak

A memory leak in Java occurs when objects that are no longer needed by the application remain reachable through strong references, preventing the garbage collector from reclaiming their memory. Unlike C or C++, Java cannot leak memory through forgotten deallocation — the GC handles that. Java leaks memory through forgotten references: objects that the application logic no longer needs but that are still reachable through data structures, caches, listeners, or static fields. Memory leaks manifest as growing heap usage, increasing GC frequency and duration, and ultimately OutOfMemoryError. This entry covers every major leak pattern in Java, how to detect and diagnose leaks, how to fix each pattern, and tools for prevention.

Memory Leak Patterns — Static Fields

Static fields are the most common and most dangerous source of memory leaks in Java. A static field exists for the lifetime of the ClassLoader that loaded the class — in a standard application, this means the lifetime of the JVM. Any object reachable through a static field is permanently live: it cannot be collected regardless of whether the application logic still needs it. The classic static collection leak occurs when a Map, List, or Set is declared static and objects are added to it but never removed. Each added object is strongly reachable through the collection through the static field. If the application adds objects proportional to request volume or time (caching user sessions, recording request metadata, accumulating log entries), the collection grows without bound. The JVM eventually throws OutOfMemoryError when the heap is exhausted. The ThreadLocal leak is a variant of the static field leak that is particularly treacherous. ThreadLocal values are stored in a map on each thread. In thread pool environments (web servers, executor services), threads are reused across requests. If a ThreadLocal value is set for a request and not cleared when the request completes, it persists on the pooled thread for all subsequent requests that reuse that thread. If the ThreadLocal holds a large object or one with expensive cleanup, this constitutes a memory and resource leak. The fix is always to clear ThreadLocal values in a finally block or a servlet filter.
Java
// ── Pattern 1: Unbounded static collection ───────────────────────────
// LEAK: SessionCache grows without bound
public class SessionManager {
    // LEAK: entries added but never removed
    private static final Map<String, UserSession> SESSIONS =
        new HashMap<>();

    public void createSession(String token, UserSession session) {
        SESSIONS.put(token, session);  // grows forever
        // When sessions expire, they are still in the map!
    }
}

// FIX 1: Remove expired entries
public void invalidateSession(String token) {
    SESSIONS.remove(token);  // explicit removal
}

// FIX 2: Bounded LRU cache with automatic eviction
private static final Map<String, UserSession> SESSIONS =
    Collections.synchronizedMap(
        new LinkedHashMap<String, UserSession>(1000, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(
                    Map.Entry<String, UserSession> eldest) {
                return size() > 1000;  // evict when exceeding limit
            }
        });

// FIX 3: Use Caffeine or Guava Cache with expiry
private static final Cache<String, UserSession> SESSIONS =
    Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterAccess(30, TimeUnit.MINUTES)
        .build();

// ── Pattern 2: ThreadLocal leak in thread pools ───────────────────────
// LEAK: RequestContext set but not cleared — leaks to next request
public class RequestFilter implements Filter {

    static final ThreadLocal<RequestContext> CONTEXT =
        new ThreadLocal<>();

    @Override
    public void doFilter(ServletRequest req, ServletResponse res,
                         FilterChain chain) throws Exception {
        CONTEXT.set(new RequestContext(req));   // set for this request
        chain.doFilter(req, res);
        // LEAK: if exception thrown, CONTEXT is never cleared!
        // Thread returns to pool with stale context for next request
    }
}

// FIX: clear in finally
@Override
public void doFilter(ServletRequest req, ServletResponse res,
                     FilterChain chain) throws Exception {
    CONTEXT.set(new RequestContext(req));
    try {
        chain.doFilter(req, res);
    } finally {
        CONTEXT.remove();   // ALWAYS clear — even if exception thrown
    }
}

Memory Leak Patterns — Listeners and Callbacks

The observer pattern is the second most common source of memory leaks. An observable object (a model, an event bus, a message broker) holds a list of listeners. Each listener holds a reference to the object that registered it. If the listener is never deregistered, the observable keeps the listener alive, and the listener keeps its enclosing object alive. For a GUI component: a view registers a listener with a model; when the view is closed and should be collected, the model still holds a reference to the listener; the listener (as an inner class) holds a reference to the view; the view is never collected. This listener leak pattern is so common that it has a well-known name: the lapsed listener problem. The fix is always explicit deregistration when the subscriber is no longer needed. For GUI components, deregister in the dispose or close lifecycle method. For application-level event buses, deregister in shutdown hooks or component lifecycle methods. Anonymous inner classes and lambda listeners exacerbate this problem because they implicitly capture a reference to their enclosing instance. A lambda registered as a listener on a long-lived event bus holds a reference to the object that registered it, keeping that object alive. Using static methods or explicit method references to static methods avoids this capture.
Java
// ── Pattern 3: Listener not deregistered (lapsed listener) ──────────
public class LiveDataMonitor {

    // Long-lived: survives for the application lifetime
    private final MetricsCollector metricsCollector;

    // Short-lived: should be collected when this Monitor is closed
    private final AlertService alertService;

    public LiveDataMonitor(MetricsCollector collector,
                           AlertService alerts) {
        this.metricsCollector = collector;
        this.alertService     = alerts;

        // LEAK: anonymous class captures 'this' (LiveDataMonitor)
        // metricsCollector holds a reference to the lambda
        // lambda holds a reference to LiveDataMonitor via captured 'this'
        // LiveDataMonitor can never be collected while metricsCollector lives
        metricsCollector.addListener(metric -> {
            if (metric.value() > THRESHOLD) {
                alertService.alert(metric);  // captures 'this'
            }
        });
    }
}

// FIX: keep reference to listener and deregister on close
public class LiveDataMonitor implements AutoCloseable {

    private final MetricsCollector metricsCollector;
    private final Consumer<Metric> listener;   // hold a reference

    public LiveDataMonitor(MetricsCollector collector,
                           AlertService alerts) {
        this.metricsCollector = collector;
        this.listener = metric -> {
            if (metric.value() > THRESHOLD) {
                alerts.alert(metric);
            }
        };
        collector.addListener(this.listener);  // register
    }

    @Override
    public void close() {
        metricsCollector.removeListener(this.listener);  // deregister!
    }
}

// Usage with try-with-resources:
try (LiveDataMonitor monitor = new LiveDataMonitor(collector, alerts)) {
    // ... use monitor ...
}   // close() called → listener deregistered → monitor can be GC'd

// ── Pattern 4: Inner class holding outer reference ────────────────────
public class DataProcessor {

    private final byte[] largeBuffer = new byte[100_000_000];  // 100MB

    // LEAK: non-static inner class holds implicit reference to DataProcessor
    public Runnable createTask() {
        return new Runnable() {   // holds DataProcessor.this
            @Override
            public void run() {
                // even if largeBuffer is not used, DataProcessor is retained
                System.out.println("Task running");
            }
        };
    }

    // FIX: static nested class — no outer reference
    private static class StaticTask implements Runnable {
        @Override
        public void run() {
            System.out.println("Task running");  // no DataProcessor reference
        }
    }

    public Runnable createSafeTask() {
        return new StaticTask();  // DataProcessor can be GC'd
    }
}

Detecting and Diagnosing Memory Leaks

Detecting memory leaks requires systematic observation and analysis. The first signal is consistently growing heap usage over time — even after GC cycles. A healthy application's heap oscillates between high usage (just before GC) and low usage (just after GC), but the post-GC baseline remains stable. A leaking application's post-GC baseline creeps upward indefinitely. Heap dumps are the primary diagnostic tool. A heap dump is a snapshot of all objects on the heap at a moment in time, including their types, sizes, and reference chains to GC roots. Heap dumps can be generated automatically on OutOfMemoryError (-XX:+HeapDumpOnOutOfMemoryError), manually via jcmd, or via monitoring tools. Eclipse Memory Analyzer (MAT) and IntelliJ's heap analyser can identify the "leak suspects" — the objects consuming the most memory and the reference chain that keeps them alive. The dominated-by analysis in Eclipse MAT is the most useful technique: it shows which objects "dominate" (are the primary cause of retention for) the most memory. A HashMapEntry that dominates 2GB of memory suggests that a HashMap is holding objects that should have been released. The "path to GC roots" feature then shows the chain of references from the HashMap to a GC root, identifying exactly which static field, thread local, or listener registry is causing the leak. jstat, JFR (Java Flight Recorder), and async-profiler's allocation profiling complement heap dump analysis. jstat -gcutil shows live set growth over time. JFR records object allocation with stack traces, identifying which code paths are responsible for the allocating the most retained objects.
Java
// ── JVM flags for leak detection ─────────────────────────────────────
// java -XX:+HeapDumpOnOutOfMemoryError        automatically dump on OOM
// java -XX:HeapDumpPath=/var/dumps/heap.hprof path for heap dump
// java -XX:+FlightRecorder                    enable JFR
// java -Xlog:gc*                              GC logging for heap growth trend

// ── jstat — monitor heap over time ───────────────────────────────────
// jstat -gcutil <pid> 5000 60
// (Print every 5 seconds for 60 iterations)
//
// S0     S1     E      O      M     CCS    YGC   YGCT  FGC  FGCT    GCT
// 0.00  43.21  56.78  12.34  96.78  91.23  342   1.234   0   0.000  1.234
// 0.00  43.21  67.89  12.34  96.78  91.23  343   1.245   0   0.000  1.245
// 0.00  43.21  23.45  13.56  96.78  91.23  344   1.256   0   0.000  1.256
//                            ↑ O (Old gen) rising after each GC = LEAK
//
// Healthy: O stays roughly constant between major collections
// Leak: O drifts upward across many GC cycles

// ── jcmd for heap dump ────────────────────────────────────────────────
// jcmd <pid> GC.heap_dump /tmp/heap.hprof
// jcmd <pid> VM.native_memory      (see all memory regions)
// jcmd <pid> GC.heap_info          (quick heap summary)

// ── Eclipse MAT queries (OQL) ─────────────────────────────────────────
// Find all HashMap instances > 100MB:
// SELECT * FROM java.util.HashMap WHERE
//     totalSize(this) > 100 * 1024 * 1024

// Find instances of a specific type:
// SELECT * FROM com.myapp.UserSession

// Find path from a suspected leaker to GC root via MAT GUI:
// Heap Dump → Dominator Tree → Right-click → Path To GC Roots

// ── Systematic leak hunt process ─────────────────────────────────────
// 1. Observe heap growth over time with jstat or monitoring
// 2. Take heap dump when heap is large (not right after startup)
// 3. Open in Eclipse MAT: Leak Suspects Report → automated analysis
// 4. Look at Dominator Tree → largest objects retained by single path
// 5. "Path To GC Roots" on suspected objects → identify retaining path
// 6. The retaining path leads to: static field, ThreadLocal, listener, cache
// 7. Fix the retention by removing references when no longer needed
// 8. Re-run and verify heap stabilises

Memory Leak Patterns — Caches and ClassLoader Leaks

Caches are the most legitimate cause of memory growth — caching is intentional. Caches become leaks when they grow without bound, have no eviction policy, or hold objects that are far larger than their utility justifies. Every production cache should have: a maximum size, an eviction policy (LRU, LFU, time-based expiry), and monitoring for hit rate and memory usage. A cache that grows indefinitely is a memory leak with a performance justification that will eventually be consumed by the OOM it causes. ClassLoader leaks are the hardest to diagnose and the most consequential for application servers. When an application is undeployed and redeployed, a new ClassLoader is created for the new version. The old ClassLoader should be collected, freeing all class metadata and all instances of the old application's classes. But if any single reference from the server's ClassLoader (or any long-lived shared ClassLoader) to an object of the old application's class exists, the entire old ClassLoader is retained — including all class metadata (in Metaspace), all static field values, and all singletons. Repeated redeployments accumulate ClassLoaders until Metaspace is exhausted. Common causes of ClassLoader leaks: JDBC drivers registered in DriverManager but not deregistered on undeploy; JUL (java.util.logging) handlers registered on the root logger; ThreadLocals set by application code in the server's threads; static references from shared library code to application objects; and runtime-generated proxies whose generating ClassLoader is the application ClassLoader.
Java
// ── Pattern 5: Cache without eviction ────────────────────────────────
// LEAK: cache grows without bound
public class ImageCache {
    private static final Map<String, BufferedImage> CACHE = new HashMap<>();

    public static BufferedImage get(String url) {
        return CACHE.computeIfAbsent(url, ImageCache::loadImage);
        // Every URL ever loaded is kept in memory FOREVER
    }
}

// FIX: use Caffeine with size and time limits
private static final Cache<String, BufferedImage> CACHE =
    Caffeine.newBuilder()
        .maximumSize(500)                          // max 500 images
        .expireAfterAccess(1, TimeUnit.HOURS)      // evict after 1h idle
        .softValues()                               // allow GC under pressure
        .recordStats()                             // monitor hit rate
        .build();

// ── Pattern 6: ClassLoader leak — JDBC driver not deregistered ────────
// Application registers driver on startup:
public class AppServletContextListener implements ServletContextListener {

    @Override
    public void contextInitialized(ServletContextEvent sce) {
        // JDBC driver registered in shared DriverManager
        // DriverManager holds reference to driver class
        // Driver class is loaded by app ClassLoader
        // DriverManager survives undeployment → app ClassLoader retained
    }

    @Override
    public void contextDestroyed(ServletContextEvent sce) {
        // FIX: deregister JDBC drivers on undeploy
        Enumeration<Driver> drivers = DriverManager.getDrivers();
        while (drivers.hasMoreElements()) {
            Driver driver = drivers.nextElement();
            // Only deregister drivers from OUR ClassLoader
            if (driver.getClass().getClassLoader()
                    == getClass().getClassLoader()) {
                try {
                    DriverManager.deregisterDriver(driver);
                } catch (SQLException e) {
                    log.warn("Failed to deregister JDBC driver", e);
                }
            }
        }

        // Also shutdown any connection pools
        if (dataSource instanceof HikariDataSource) {
            ((HikariDataSource) dataSource).close();
        }
    }
}

// ── Checking for ClassLoader leaks ────────────────────────────────────
// After undeployment, check if old ClassLoader was collected:
// jcmd <pid> VM.classloader_stats
// If you see multiple instances of your app ClassLoader after redeployment,
// you have a ClassLoader leak

// Heap dump analysis for ClassLoader leaks:
// Eclipse MAT → OQL:
// SELECT * FROM java.lang.ClassLoader WHERE
//     toString(this) LIKE "*WebappClassLoader*"
// If count > 1 per deployment, ClassLoaders are leaking

Related Topics in Java Memory Management

Stack Memory
Stack memory is the region of memory where the JVM stores method invocation frames, local variables, and partial results. Every thread has its own private stack created at thread creation, and the stack grows and shrinks as methods are called and return. Stack memory operates on a last-in, first-out discipline — the frame for the most recently called method sits on top, and when that method returns its frame is immediately discarded. Understanding stack memory explains why local variables are thread-safe by default, why recursive algorithms can cause StackOverflowError, why primitive values behave differently from objects, and what the JVM does at every method call and return. This entry covers stack frame structure, the stack pointer, local variable storage, operand stacks, frame lifecycle, thread isolation, and the performance characteristics that make stack allocation extremely fast.
Heap Memory
The heap is the runtime data area from which all Java object instances and arrays are allocated. It is shared across all threads in a JVM process, grows dynamically up to a configured maximum, and is managed entirely by the garbage collector. Understanding heap memory means understanding how objects are allocated, how the generational hypothesis drives GC design, how the major garbage collectors (G1, ZGC, Shenandoah, Parallel) partition and manage the heap, what triggers garbage collection, how to tune heap size and GC behaviour, and how to diagnose heap-related problems including OutOfMemoryError and excessive GC pause times. This entry covers heap structure, generational design, object allocation, garbage collection triggers, common collectors, heap tuning, and memory leak detection.
Metaspace
Metaspace is the JVM memory region that stores class metadata — the internal representations of loaded classes, methods, fields, constant pools, and annotations. It replaced PermGen (Permanent Generation) in Java 8. Unlike PermGen which was a fixed-size heap region, Metaspace is allocated from native memory (outside the Java heap) and grows dynamically up to an optional maximum. Understanding Metaspace means understanding what class metadata contains, what causes Metaspace to grow, how class unloading reclaims Metaspace, what OutOfMemoryError from Metaspace looks like, how to monitor and limit it, and what the practical implications are for application servers, OSGi containers, and frameworks that generate classes dynamically.
JVM Memory Model
The JVM Memory Model encompasses two related but distinct concepts. The first is the runtime memory architecture — how the JVM partitions memory into regions (stack, heap, Metaspace, code cache, native memory) and what each region stores. The second, more precise meaning is the Java Memory Model (JMM) defined in the Java Language Specification — the formal set of rules that govern how multithreaded programs observe memory: when writes made by one thread become visible to other threads, what ordering guarantees exist, and how volatile, synchronized, and final provide those guarantees. This entry covers both: the complete runtime memory architecture with all regions explained, and the Java Memory Model's happens-before relationship, visibility rules, and the practical consequences for concurrent code.