AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

16 Memory and the JVM runtime / Lesson 6 of 6

Diagnosing memory problems — leaks, dumps and bounded caches

14 min · +10 XP · 2 exercises · 5 questions

Read the text14 min
Exercises0/2
Quiz5 q

Quick answer

How do you find a memory leak in Java?

To find a memory leak in Java, watch old-generation occupancy with jstat -gcutil: with a leak it climbs after every full GC and never drops. Take a heap dump with -XX:+HeapDumpOnOutOfMemoryError or jcmd, open it in Eclipse MAT or VisualVM, and let the dominator tree name the object retaining the most memory; its path to GC roots shows why it is still reachable.

Everything so far has been the model. This lesson is what you do at two in the morning when the service falls over with OutOfMemoryError, and, more usefully, what you build in advance so it does not. The skills: reading the different OOM messages, taking and reading a heap dump, spotting the six leak shapes in code review, and writing the bounded structures — an LRU cache above all — that keep memory finite by construction.

The messages, and what each means

MessageMeaningFirst move
Java heap spacelive objects exceed -Xmxheap dump; find the biggest retained set
GC overhead limit exceeded>98% of time in GC, <2% recovered — effectively the same as abovesame
Metaspaceclass metadata exhausted — classes loaded and never unloadedclass-loader leak: redeploys, dynamic proxies, generated classes
unable to create native threadOS thread limit or per-thread stack memory exhaustedthread leak: unbounded new Thread, executors never shut down
Direct buffer memoryByteBuffer.allocateDirect beyond -XX:MaxDirectMemorySizebuffers not released; NIO frameworks
Requested array size exceeds VM limitasked for an array near Integer.MAX_VALUEa size computation bug, or an unbounded read into one array

Only the first two are about your objects; the others are about resources the heap size does not govern. Raising -Xmx fixes none of them and merely delays a leak.

Taking the evidence

  • -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps — the flags to have on before the incident. A dump is a snapshot of every object and reference; without it you are guessing.
  • jcmd <pid> GC.heap_dump /tmp/heap.hprof — on demand, from a live process. jmap -dump:live,format=b,file=… is the older spelling; live forces a GC first so you see only reachable objects.
  • jcmd <pid> GC.class_histogram (or jmap -histo:live <pid>) — the quick look: instance counts and bytes per class. If the top line is [B (byte arrays) or String, look at who holds them, not at the strings.
  • jstat -gcutil <pid> 1s — live occupancy of each generation and GC counts per second. Old-generation occupancy that climbs after every full GC and never drops is the signature of a leak.
  • JFR (-XX:StartFlightRecording) and JDK Mission Control — continuous low-overhead recording of allocations, GC, threads; the production-safe profiler.
  • Eclipse MAT or VisualVM to open the dump: the dominator tree answers "which object keeps the most memory alive", and path to GC roots explains why it is still reachable.

Reading a histogram

 num     #instances         #bytes  class name
   1:       1204411       57811728  [B
   2:       1204013       28896312  java.lang.String
   3:         12034       19311776  [Ljava.lang.Object;
   4:          9021        1010352  java.util.HashMap$Node

A million strings is not a diagnosis; who holds a million strings is. The Object[] at line 3 — 12 000 arrays averaging 1.6 KB — is likelier to be the interesting party: the backing arrays of ArrayLists. In the dominator tree you would find, say, one ConcurrentHashMap in a static field retaining 80 MB. That field is the leak.

The six leak shapes to catch in review

  1. The growing static collection — a cache, registry or "seen" set with put and no remove, no bound, no expiry.
  2. The unremoved listener — subject.addListener(this) in a constructor with no matching removal; the long-lived subject keeps every short-lived listener.
  3. ThreadLocal in a pool — set per request, never remove()d; the pooled thread lives for days.
  4. The unclosed resource — streams, connections, Scanners, ExecutorServices without shutdown(). Try-with-resources for the first three; an explicit lifecycle for the last.
  5. The captured outer — a lambda or inner class stored in a static or a long-lived collection, dragging its enclosing object along.
  6. The mutable key — a HashMap key whose hashCode changed after insertion: the entry is now unreachable through get and unremovable through remove, so it is a leak and a bug.

Each has a two-line fix. Finding them is a matter of asking, for every collection that lives longer than a request, "what removes things from this?"

Bounded by construction: the LRU cache

The standard answer to "cache without leaking" is a bounded, least-recently-used map, and Java's LinkedHashMap was designed to be one:

class Lru<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;
    Lru(int capacity) {
        super(16, 0.75f, true);          // accessOrder = true: get() moves the entry to the tail
        this.capacity = capacity;
    }
    @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;        // called after every put; true evicts the head (least recent)
    }
}

accessOrder = true makes both get and put move an entry to the most-recent end; removeEldestEntry is consulted after each insertion. Twenty lines, O(1) per operation, and the interview's "design an LRU cache" solved with the standard library — then be ready to describe the hand-built version (a HashMap plus a doubly linked list) that it wraps. For a concurrent or expiry-based cache, use Caffeine; do not write one.

Other bounded shapes: a Deque trimmed to the last n events; a PriorityQueue kept at size k for top-k; WeakHashMap for metadata about objects you do not own; a SoftReference only when you have measured that it helps.

Allocation hygiene in hot paths

  • Prefer primitives and primitive arrays; IntStream, not Stream<Integer>; int counters, not Integer.
  • Reuse buffers (byte[], StringBuilder.setLength(0)) across iterations of a hot loop when the profiler says allocation matters — and only then; premature reuse is how shared mutable state is born.
  • Pre-size collections when you know the count: new ArrayList<>(n), new HashMap<>(n * 4 / 3 + 1).
  • Avoid String.format and regex in tight loops; both allocate freely.
  • Stream Files.lines instead of readAllLines for large files; read a big input once with readAllBytes rather than line by line into a growing list you then join.

Interview angle

  • "How would you find a memory leak?" Watch old-gen occupancy with jstat; take a heap dump (-XX:+HeapDumpOnOutOfMemoryError or jcmd); open it in MAT; the dominator tree names the retaining collection; trace the path to GC roots.
  • "Name common leak causes." Growing static collections, unremoved listeners, ThreadLocals in pools, unclosed resources, captured outer instances, mutable keys.
  • "Design an LRU cache." LinkedHashMap in access order with removeEldestEntry; or a HashMap + doubly linked list by hand, both O(1).
  • "What is GC overhead limit exceeded?" The JVM spending almost all its time collecting and recovering almost nothing — the heap is full of live data.
  • "Does raising -Xmx fix an OOM?" Only when the working set genuinely needs it; for a leak it postpones the crash.

Key takeaways

  • Read the OOM message: heap, Metaspace, threads and direct memory are different problems.
  • Have dump-on-OOM on; jcmd, jstat, jmap -histo, JFR and MAT are the kit; the dominator tree names the culprit.
  • Six leak shapes; the review question is "what removes entries from this?"
  • Bound everything long-lived: LRU via LinkedHashMap(accessOrder = true) + removeEldestEntry.
  • Allocate less in hot paths — primitives, pre-sizing, streaming input — after measuring.

Common questions

How do you implement an LRU cache in Java?

Extend LinkedHashMap with access order switched on (super(16, 0.75f, true)), so get and put move an entry to the most-recent end, and override removeEldestEntry to return size() > capacity. Every operation is O(1); the hand-built alternative is a HashMap plus a doubly linked list.

How do you take a heap dump in Java?

Run jcmd with GC.heap_dump and a file path against a live process, or start the JVM with -XX:+HeapDumpOnOutOfMemoryError so a dump is written at the moment of failure. jmap -dump:live is the older spelling, and live forces a GC first so only reachable objects are recorded.

What does OutOfMemoryError Metaspace mean?

Class metadata has run out because classes are loaded and never unloaded. It is usually a class-loader leak from redeploys, dynamic proxies or generated classes, and raising -Xmx does not help, because Metaspace is not part of the heap.

What does OutOfMemoryError unable to create native thread mean?

The operating system's thread limit, or the memory for per-thread stacks, is exhausted. It usually means a thread leak — threads created without bound, or executors that are never shut down — and has nothing to do with the heap size.

Does increasing -Xmx fix an OutOfMemoryError?

Only when the working set genuinely needs more memory. For a leak it merely postpones the crash, and it does nothing for Metaspace, thread or direct-buffer exhaustion, which the heap size does not govern.

Mark as readSign in to keep your place.

How to Find a Memory Leak in Java: Heap Dumps and LRU Caches

Find a Java memory leak with jstat, a heap dump and Eclipse MAT, read each OutOfMemoryError message, and keep caches bounded with an LRU LinkedHashMap.

  • Course: Java study plan
  • Module: Memory and the JVM runtime
  • Kind: Lesson
  • Reading time: 14 min
  • Runtime: OpenJDK 18

How do you find a memory leak in Java?

To find a memory leak in Java, watch old-generation occupancy with jstat -gcutil: with a leak it climbs after every full GC and never drops. Take a heap dump with -XX:+HeapDumpOnOutOfMemoryError or jcmd, open it in Eclipse MAT or VisualVM, and let the dominator tree name the object retaining the most memory; its path to GC roots shows why it is still reachable.

Lesson

Everything so far has been the model. This lesson is what you do at two in the morning when the service falls over with OutOfMemoryError, and, more usefully, what you build in advance so it does not. The skills: reading the different OOM messages, taking and reading a heap dump, spotting the six leak shapes in code review, and writing the bounded structures — an LRU cache above all — that keep memory finite by construction.

The messages, and what each means

MessageMeaningFirst move
Java heap spacelive objects exceed -Xmxheap dump; find the biggest retained set
GC overhead limit exceeded>98% of time in GC, <2% recovered — effectively the same as abovesame
Metaspaceclass metadata exhausted — classes loaded and never unloadedclass-loader leak: redeploys, dynamic proxies, generated classes
unable to create native threadOS thread limit or per-thread stack memory exhaustedthread leak: unbounded new Thread, executors never shut down
Direct buffer memoryByteBuffer.allocateDirect beyond -XX:MaxDirectMemorySizebuffers not released; NIO frameworks
Requested array size exceeds VM limitasked for an array near Integer.MAX_VALUEa size computation bug, or an unbounded read into one array

Only the first two are about your objects; the others are about resources the heap size does not govern. Raising -Xmx fixes none of them and merely delays a leak.

Taking the evidence

  • -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps — the flags to have on before the incident. A dump is a snapshot of every object and reference; without it you are guessing.
  • jcmd <pid> GC.heap_dump /tmp/heap.hprof — on demand, from a live process. jmap -dump:live,format=b,file=… is the older spelling; live forces a GC first so you see only reachable objects.
  • jcmd <pid> GC.class_histogram (or jmap -histo:live <pid>) — the quick look: instance counts and bytes per class. If the top line is [B (byte arrays) or String, look at who holds them, not at the strings.
  • jstat -gcutil <pid> 1s — live occupancy of each generation and GC counts per second. Old-generation occupancy that climbs after every full GC and never drops is the signature of a leak.
  • JFR (-XX:StartFlightRecording) and JDK Mission Control — continuous low-overhead recording of allocations, GC, threads; the production-safe profiler.
  • Eclipse MAT or VisualVM to open the dump: the dominator tree answers "which object keeps the most memory alive", and path to GC roots explains why it is still reachable.

Reading a histogram

 num     #instances         #bytes  class name
   1:       1204411       57811728  [B
   2:       1204013       28896312  java.lang.String
   3:         12034       19311776  [Ljava.lang.Object;
   4:          9021        1010352  java.util.HashMap$Node

A million strings is not a diagnosis; who holds a million strings is. The Object[] at line 3 — 12 000 arrays averaging 1.6 KB — is likelier to be the interesting party: the backing arrays of ArrayLists. In the dominator tree you would find, say, one ConcurrentHashMap in a static field retaining 80 MB. That field is the leak.

The six leak shapes to catch in review

  1. The growing static collection — a cache, registry or "seen" set with put and no remove, no bound, no expiry.
  2. The unremoved listener — subject.addListener(this) in a constructor with no matching removal; the long-lived subject keeps every short-lived listener.
  3. ThreadLocal in a pool — set per request, never remove()d; the pooled thread lives for days.
  4. The unclosed resource — streams, connections, Scanners, ExecutorServices without shutdown(). Try-with-resources for the first three; an explicit lifecycle for the last.
  5. The captured outer — a lambda or inner class stored in a static or a long-lived collection, dragging its enclosing object along.
  6. The mutable key — a HashMap key whose hashCode changed after insertion: the entry is now unreachable through get and unremovable through remove, so it is a leak and a bug.

Each has a two-line fix. Finding them is a matter of asking, for every collection that lives longer than a request, "what removes things from this?"

Bounded by construction: the LRU cache

The standard answer to "cache without leaking" is a bounded, least-recently-used map, and Java's LinkedHashMap was designed to be one:

class Lru<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;
    Lru(int capacity) {
        super(16, 0.75f, true);          // accessOrder = true: get() moves the entry to the tail
        this.capacity = capacity;
    }
    @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;        // called after every put; true evicts the head (least recent)
    }
}

accessOrder = true makes both get and put move an entry to the most-recent end; removeEldestEntry is consulted after each insertion. Twenty lines, O(1) per operation, and the interview's "design an LRU cache" solved with the standard library — then be ready to describe the hand-built version (a HashMap plus a doubly linked list) that it wraps. For a concurrent or expiry-based cache, use Caffeine; do not write one.

Other bounded shapes: a Deque trimmed to the last n events; a PriorityQueue kept at size k for top-k; WeakHashMap for metadata about objects you do not own; a SoftReference only when you have measured that it helps.

Allocation hygiene in hot paths

  • Prefer primitives and primitive arrays; IntStream, not Stream<Integer>; int counters, not Integer.
  • Reuse buffers (byte[], StringBuilder.setLength(0)) across iterations of a hot loop when the profiler says allocation matters — and only then; premature reuse is how shared mutable state is born.
  • Pre-size collections when you know the count: new ArrayList<>(n), new HashMap<>(n * 4 / 3 + 1).
  • Avoid String.format and regex in tight loops; both allocate freely.
  • Stream Files.lines instead of readAllLines for large files; read a big input once with readAllBytes rather than line by line into a growing list you then join.

Interview angle

  • "How would you find a memory leak?" Watch old-gen occupancy with jstat; take a heap dump (-XX:+HeapDumpOnOutOfMemoryError or jcmd); open it in MAT; the dominator tree names the retaining collection; trace the path to GC roots.
  • "Name common leak causes." Growing static collections, unremoved listeners, ThreadLocals in pools, unclosed resources, captured outer instances, mutable keys.
  • "Design an LRU cache." LinkedHashMap in access order with removeEldestEntry; or a HashMap + doubly linked list by hand, both O(1).
  • "What is GC overhead limit exceeded?" The JVM spending almost all its time collecting and recovering almost nothing — the heap is full of live data.
  • "Does raising -Xmx fix an OOM?" Only when the working set genuinely needs it; for a leak it postpones the crash.

Key takeaways

  • Read the OOM message: heap, Metaspace, threads and direct memory are different problems.
  • Have dump-on-OOM on; jcmd, jstat, jmap -histo, JFR and MAT are the kit; the dominator tree names the culprit.
  • Six leak shapes; the review question is "what removes entries from this?"
  • Bound everything long-lived: LRU via LinkedHashMap(accessOrder = true) + removeEldestEntry.
  • Allocate less in hot paths — primitives, pre-sizing, streaming input — after measuring.

Common questions

How do you implement an LRU cache in Java?

Extend LinkedHashMap with access order switched on (super(16, 0.75f, true)), so get and put move an entry to the most-recent end, and override removeEldestEntry to return size() > capacity. Every operation is O(1); the hand-built alternative is a HashMap plus a doubly linked list.

How do you take a heap dump in Java?

Run jcmd with GC.heap_dump and a file path against a live process, or start the JVM with -XX:+HeapDumpOnOutOfMemoryError so a dump is written at the moment of failure. jmap -dump:live is the older spelling, and live forces a GC first so only reachable objects are recorded.

What does OutOfMemoryError Metaspace mean?

Class metadata has run out because classes are loaded and never unloaded. It is usually a class-loader leak from redeploys, dynamic proxies or generated classes, and raising -Xmx does not help, because Metaspace is not part of the heap.

What does OutOfMemoryError unable to create native thread mean?

The operating system's thread limit, or the memory for per-thread stacks, is exhausted. It usually means a thread leak — threads created without bound, or executors that are never shut down — and has nothing to do with the heap size.

Does increasing -Xmx fix an OutOfMemoryError?

Only when the working set genuinely needs more memory. For a leak it merely postpones the crash, and it does nothing for Metaspace, thread or direct-buffer exhaustion, which the heap size does not govern.

Exercises

LRU cache

Build a least-recently-used cache on LinkedHashMap with accessOrder = true and removeEldestEntry. Read the capacity c, then an integer n and n commands: put <k> <v> (when it evicts, print evict <key>); get <k> prints get <k> -> <v> or get <k> -> miss. Finally print keys=<the keys from least to most recently used, as a list> and hits=<n> misses=<n>.

Example: c = 2, then 6 commands: put a 1, put b 2, get a, put c 3, get b, get c →

get a -> 1
evict b
get b -> miss
get c -> 3
keys=[a, c]
hits=2 misses=1

Heap histogram

Read allocation records <class> <bytes> — one object each — until end of input, and print a class histogram the way jmap -histo does: one line per class, <rank>: <class> instances=<count> bytes=<total>, ordered by total bytes descending then class name, then total=<all bytes> and top=<share of the top class, whole percent>%.

Example input

String 40
byte[] 1000
String 40
Object[] 500
byte[] 200

→

1: byte[] instances=2 bytes=1200
2: Object[] instances=1 bytes=500
3: String instances=2 bytes=80
total=1780
top=67%

In this module: Memory and the JVM runtime

← Class loading, initialisation order and the JIT · Checkpoint — Memory and the JVM runtime →