What is a memory leak and how to detect it
In Java, a Memory Leak occurs when objects that are no longer needed by application business logic remain unintentionally anchored to a live GC Root via active strong references...
🟢 Junior Level
In Java, a Memory Leak occurs when objects that are no longer needed by application business logic remain unintentionally anchored to a live GC Root via active strong references, preventing the Garbage Collector from freeing their memory.
Real-World Analogy
Think of a hotel room keycard system:
- When a guest checks out, hotel policy requires their keycard to be deactivated and shredded.
- If the front desk clerk forgets to deactivate the card, the reservation system marks the room as “Permanently Occupied”.
- Housekeeping (Garbage Collector) is strictly forbidden from cleaning or reassigning occupied rooms.
- Over time, every room in the hotel is marked occupied by phantom reservations, preventing new guests from checking in (
OutOfMemoryError).
Telemetry Signature: The Rising Sawtooth Pattern
Memory telemetry graphs (in Grafana, Datadog, or Prometheus) display a distinctive footprint when an application suffers from a memory leak:
Healthy JVM (Stable Floor): Leaking JVM (Rising Baseline Floor):
Heap MB Heap MB
| /\ /\ /\ /\ | /\ /\ /\ / \ [OOM Crash!]
| / \ / \ / \ / \ | / \ / \ / \ / \
| / \ / \ / \ / \ | / \ / \ / \ / \
|_/______V______V______V______\ |___/______\_/______\_/______\_/_______
+-----------------------------> Time +-------------------------------------> Time
(Post-GC floor remains flat) (Post-GC floor creeps steadily upward!)
- Healthy Footprint: After every Major or Full GC, heap occupancy drops back to a consistent baseline floor (the working set).
- Leaking Footprint (Baseline Creep): After every GC cycle, the post-collection floor drifts steadily upward. The collector works harder and harder (GC Thrashing, consuming 95%+ of CPU time) while freeing almost no memory, eventually throwing
java.lang.OutOfMemoryError: Java heap space.
🟡 Middle Level
The 5-Step Heap Delta Analysis Methodology
Never inspect a single, isolated heap dump in a vacuum. A single snapshot only shows current occupancy, not growth trends. Always employ Heap Delta Analysis:
1. Startup & Warm-up ──> 2. Baseline Dump T0 ──> 3. Execute Load Test ──> 4. Cooldown & Target Dump T1
│
▼
5. Compare Snapshot Diff in Eclipse MAT
- Warm-up: Start the JVM and execute warm-up requests to ensure class loading, JIT compilations, and connection pools reach a steady state.
- Baseline Snapshot ($T_0$): Trigger an initial heap dump:
jcmd <PID> GC.heap_dump /dumps/baseline_t0.hprof. - Execute Workload: Subject the service to an isolated automated load test (e.g., executing 50,000 checkout workflows).
- Cooldown & Target Snapshot ($T_1$): Allow running threads to complete, wait for active transactions to settle, and capture the second dump:
jcmd <PID> GC.heap_dump /dumps/target_t1.hprof. - Diff Comparison: Open both dumps in Eclipse MAT (Memory Analyzer Tool) and select Compare Snapshots:
- Objects whose instance counts increased linearly with transaction volume represent your memory leak!
Shallow Heap vs. Retained Heap
Understanding the distinction between Shallow Heap and Retained Heap is essential when navigating memory profilers:
┌────────────────────────────────────────────────────────┬────────────────────────────────────────────────────────┐
│ Shallow Heap │ Retained Heap │
├────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ • Memory consumed directly by the object ITSELF. │ • Total memory that would be freed if this │
│ (Object header + primitive fields + pointer slots) │ specific object were collected by the GC. │
│ • Example: A `ConcurrentHashMap` instance shallow │ • Example: That same `ConcurrentHashMap` holds │
│ size is typically only 64 bytes! │ 3.5 GB of cached user sessions in its subtree! │
└────────────────────────────────────────────────────────┴────────────────────────────────────────────────────────┘
[!IMPORTANT] When hunting memory leaks in Eclipse MAT or JProfiler, always sort tables by Retained Heap, never Shallow Heap!
🔴 Senior Level
Eclipse MAT Power Tools: Dominator Tree & Path to GC Roots
1. The Dominator Tree:
In graph theory, an object $X$ dominates object $Y$ if every reference path from any GC Root to $Y$ must traverse $X$.
- The Dominator Tree reorganizes billions of tangled object references into a clean tree of ownership.
- Sorting the Dominator Tree by Retained Heap instantly highlights the exact top-level object (e.g., a static cache or singleton) holding the majority of memory hostage.
Class Name | Shallow | Retained | Percentage
---------------------------------------------------+---------+----------+-----------
com.example.OrderRegistry @ 0x7fa281c00 | 48 | 2.150 GB | 78.4%
└─ java.util.concurrent.ConcurrentHashMap | 64 | 2.149 GB | 78.3%
└─ java.util.concurrent.ConcurrentHashMap$Node[] | 128 | 2.149 GB | 78.3%
2. Path to GC Roots (Excluding Weak/Soft References):
Once the leaking object is located, right-click the node $\to$ select Path to GC Roots $\to$ Exclude all phantom/weak/soft references:
[!CAUTION] Why Exclude Weak/Soft References? Weak references are reclaimed on the next GC, and Soft references are cleared before an OOM occurs. They can never cause a permanent heap leak. You must focus exclusively on Strong Reference chains to find the true root cause!
The 3 Most Common Root Culprits in Enterprise Java:
- Thread:
ThreadLocalMapvalues pinned by long-lived worker threads in thread pools. - System Class / Static Field: Static collections, caches, or singletons loaded by the application classloader.
- JNI Global Reference: Native C/C++ code that allocated a global reference to a Java object and forgot to invoke
DeleteGlobalRef().
Low-Overhead Production Profiling: JFR OldObjectSample
Triggering an on-demand heap dump on a 64 GB+ production instance (jcmd GC.heap_dump) requires a Stop-The-World pause of 30 to 90 seconds while writing gigabytes of data to disk:
- This causes load balancers to flag the pod as unhealthy and triggers Kubernetes Liveness Probe timeouts, terminating the container before the dump finishes!
✅ The Zero-Pause Production Alternative:
Use Java Flight Recorder (JFR) to sample long-lived objects continuously with $< 1\%$ CPU overhead:
# Record 30 minutes of low-overhead memory allocations:
jcmd <PID> JFR.start name=LeakProfile settings=profile duration=1800s filename=/dumps/leak.jfr
- Open
leak.jfrin JDK Mission Control (JMC). - Navigate to the Memory $\to$ Old Object Sample page.
- JFR tracks surviving objects over time, displaying their exact allocation stack traces and reference paths back to GC Roots without ever pausing the JVM!
Querying the Heap: Object Query Language (OQL)
Eclipse MAT includes OQL, allowing developers to query heap dumps using SQL-like syntax:
-- 1. Find all HashMaps holding more than 10,000 elements:
SELECT m
FROM java.util.HashMap m
WHERE m.size > 10000
-- 2. Find all ThreadLocal values containing domain contexts:
SELECT t
FROM java.lang.ThreadLocal t
-- 3. Identify duplicate Strings wasting heap memory:
SELECT toString(s) AS val, count(s) AS cnt, sum(s.@retainedHeapSize) AS totalWaste
FROM java.lang.String s
GROUP BY toString(s)
HAVING count(s) > 100
ORDER BY totalWaste DESC
4 Tricky Questions
1. What is the fundamental difference between Shallow Heap and Retained Heap, and can Retained Heap ever be smaller than Shallow Heap?
Answer: Shallow Heap is the physical memory consumed directly by the object itself (its object header, primitive instance fields, and pointer handles), typically measuring 16 to 64 bytes. Retained Heap is the total volume of memory that would be reclaimed if that specific object were garbage collected; it equals the object’s shallow size plus the memory of all other objects exclusively reachable through it. Retained Heap can NEVER be smaller than Shallow Heap, because by definition, collecting the object will at minimum free the object’s own shallow memory footprint ($\text{Retained} \ge \text{Shallow}$).
2. Why is taking a live heap dump dangerous on a 128 GB production node, and how can you safely investigate leaks without pausing the JVM?
Answer: Capturing a live heap dump via jcmd <pid> GC.heap_dump requires freezing all application mutator threads at a global Safepoint in a Stop-The-World pause. Writing 128 GB of data to disk over standard I/O channels takes between 30 and 120 seconds. During this window, active network connections time out, and container orchestrators (such as Kubernetes) fail their liveness probe health checks and issue a SIGKILL to restart the pod, corrupting the partial dump file. To safely investigate memory leaks on large heaps without STW pauses, engineers should:
- Route production traffic away from the replica (drain the pod) before capturing the dump.
- Use Java Flight Recorder (JFR) with the
jdk.OldObjectSampleevent, which continuously tracks long-lived object allocations with $<1\%$ CPU overhead and zero STW pause time.
3. Why must you always exclude soft, weak, and phantom references when tracing “Path to GC Roots” in Eclipse MAT?
Answer: In Java, memory leaks that trigger fatal OutOfMemoryError: Java heap space crashes are caused strictly by Strong References. Weak references are cleared on the very next GC cycle, and Soft references are guaranteed to be forcefully purged by the JVM before an OutOfMemoryError is thrown. Phantom references cannot be dereferenced to retrieve an object. If you do not filter out soft and weak references in Eclipse MAT, the tool may display a false path to a GC Root (e.g., an object held in an image cache via SoftReference). Filtering them out isolates the true chain of strong references that legally binds the object to a permanent GC Root.
4. How does the Dominator Tree in Eclipse MAT help pinpoint memory leaks that are obscured by complex cyclic graphs?
Answer: In enterprise applications, object graphs are filled with complex, cyclic, multi-directional relationships (e.g., graph nodes referencing each other or collections referencing shared components). In raw reference views, it is nearly impossible to tell which object is responsible for retaining the memory. The Dominator Tree simplifies this graph by computing dominance relationships: node $X$ dominates node $Y$ if and only if every reference path from every GC Root to $Y$ must pass through $X$. This transforms complex cyclic webs into a strict parent-child ownership tree. In the Dominator Tree, the root culprit of the leak appears as the top ancestor with an immense Retained Heap, immediately exposing the parent object that must be nullified or cleared to free the entire structure.
🎯 Interview Cheat Sheet
30-Second Elevator Pitch
“A Java Memory Leak occurs when obsolete objects remain reachable from GC Roots via strong reference paths, visible on telemetry as a rising post-GC baseline floor (Sawtooth Creep). To detect leaks, perform Heap Delta Analysis by comparing a baseline snapshot $T_0$ against a post-load snapshot $T_1$ in Eclipse MAT. Inside MAT, evaluate the Dominator Tree sorted by Retained Heap (the total memory freed if an object is collected) and inspect Path to GC Roots, always excluding soft and weak references. For massive production heaps ($>32\text{ GB}$) where taking a heap dump triggers dangerous 30-second STW pauses, use Java Flight Recorder (JFR)
OldObjectSamplefor zero-pause leak profiling.”
Key Architectural Takeaways
- The Visual Tell: Rising baseline floor on memory graphs after Full GCs (Sawtooth Creep).
- Delta Methodology: Always compare two snapshots ($T_0$ baseline vs $T_1$ post-load) to isolate growing instance deltas.
- Shallow vs. Retained: Shallow is the object’s own 32-byte header; Retained is the entire gigabyte subtree freed if the object dies.
- Exclude Weak/Soft: Only Strong References cause uncollectible memory leaks; always filter out weak/soft paths.
- Dominator Tree: Converts cyclic object webs into an ownership tree where top dominators reveal the leak source.
- JFR
OldObjectSample: Production-safe, zero-pause memory leak profiling with $<1\%$ CPU overhead.
Red Flags & Anti-Patterns
- 🚩 Sorting memory profiling tables by Shallow Heap: Completely conceals large maps and collections retaining gigabytes of payload.
- 🚩 Triggering uncoordinated heap dumps on high-traffic production nodes: Causes Kubernetes liveness probe failures and pod kills.
- 🚩 Assuming automatic GC prevents memory leaks: GC only collects unreachable objects; logical leaks are completely invisible to GC collectors.
- 🚩 Retaining soft/weak references when investigating GC root paths: Wastes hours chasing false-positive caching paths.
- 🚩 Executing
System.gc()on production servers before taking dumps: Triggers unnecessary, synchronous Stop-The-World Full GCs under live user traffic.
Related Topics
- What is a memory leak in Java — Theoretical foundations and leak taxonomy
- How can a memory leak occur in Java — Concrete leak code patterns and anti-patterns
- What tools help analyze memory — Profiling toolchain (MAT, JProfiler, VisualVM, JMC)
- What is a heap dump — HPROF memory snapshot capture and structure
- What are GC roots — Origins of live reference chains
- What types of OutOfMemoryError exist — Differentiating memory error manifestations