Skip to content
QSWEQB
hardScenarioConceptMidSeniorStaff

A service dies with OutOfMemoryError in production. Walk me through diagnosing it.

Read the message text first, because it names which region ran out. Then use the GC log to see whether the live set after each full collection is trending upward, which means a leak, or flat and close to the ceiling, which means the heap is simply too small. A heap dump tells you what is retaining the memory.

5 min readUpdated 2026-07-26

What the interviewer is scoring

  • Does the candidate ask what the OutOfMemoryError message said before proposing a cause
  • Whether they can separate a leak from an undersized heap using evidence rather than intuition
  • That they know memory can be exhausted outside the Java heap and can name a case
  • Whether the flags they reach for are ones that capture evidence rather than ones that hide the symptom
  • Can they describe what they would look for in a heap dump specifically, not just "open it in a tool"

Answer

Start with the message, not the theory

OutOfMemoryError is a family, and the string after the colon tells you which member you have. Java heap space means the collector could not free enough room in the heap to satisfy an allocation. Metaspace means class metadata, which lives outside the heap, filled up. Direct buffer memory means you exhausted the NIO direct-buffer budget. unable to create native thread means the OS refused a thread, which is usually address space or a process limit rather than the heap at all. Parallel GC additionally reports GC overhead limit exceeded, which is not a different resource but a different threshold: the collector is running almost continuously and reclaiming almost nothing.

Candidates who skip this step and go straight to "increase the heap" have already lost most of the marks, because three of those five messages are not made better by a larger heap and one of them is made worse.

Where memory sits under a modern collector

The default collector has been G1 since JDK 9. It divides the heap into equal-sized regions, a power of two between 1 MB and 32 MB chosen from the heap size, and each region is tagged at any moment as Eden, Survivor, Old, or Humongous. There is no contiguous young generation and no contiguous old generation, which matters for your diagnosis: a heap can fail an allocation while showing free space, because a Humongous allocation needs contiguous regions and the region set is fragmented. An object at least half a region in size is allocated as Humongous, so a heap tuned to small regions plus a workload that builds large arrays or byte buffers is a real failure mode.

ZGC is the other collector you should be able to speak about. It is region-based and does its marking and relocation concurrently, so its pause times are designed not to grow with heap size — which is the point of choosing it, rather than throughput. It has been generational since JDK 21, and since JDK 24 the non-generational mode has been removed, so "ZGC" and "generational ZGC" now mean the same thing. Resist the urge to attach numbers to any of this in an interview unless you have measured them on the system in question.

Make the next failure produce evidence

The single most valuable thing you can do before the next occurrence is to ensure the process writes a dump and a log rather than a stack trace into oblivion.

java -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/var/log/app/heap.hprof \
     -XX:+ExitOnOutOfMemoryError \
     -Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=20M \
     -jar app.jar

ExitOnOutOfMemoryError is deliberate. A JVM that has thrown OutOfMemoryError on one thread is in an unknown state — some other thread may have caught it and continued with half-built objects — so failing the process and letting the orchestrator replace it is almost always better than limping. The unified logging syntax shown here is the JDK 9 and later form; the older PrintGCDetails flags no longer exist.

On a process that is still alive, you can take the same evidence without restarting it:

jcmd "$PID" GC.heap_info
jcmd "$PID" GC.class_histogram
jcmd "$PID" GC.heap_dump /var/log/app/live.hprof

The histogram is often enough on its own, because it is cheap and it usually shows one class with an implausible instance count. Note that GC.heap_dump triggers a full collection and stops the world for the length of the write, so it is not free on a large heap.

Leak or just too small

This is the question the interviewer is really asking, and it is answered from the GC log, not the dump. What you want is the heap occupancy immediately after each full or mixed collection, because that is the live set: everything the application still reaches.

If the live set climbs monotonically across collections — each collection bottoming out higher than the last, collections becoming more frequent, and eventually the collector reclaiming almost nothing — you have a leak. Something is retaining objects that the application is finished with.

If the live set is flat but sits close to the ceiling, with collections frequent and each one reclaiming a healthy amount, the heap is undersized for the working set. The application is behaving correctly and there is simply not enough headroom. The same reading also distinguishes a third case: a live set that spikes with request volume rather than with uptime, which points at per-request allocation that is too large, such as loading a whole result set into a list.

Reading the dump

Open the .hprof in a tool that computes retained size and a dominator tree — Eclipse MAT is the usual choice. Shallow size tells you how big an object is; retained size tells you how much would be freed if it went away, and only the second one identifies a leak. Work down the dominator tree to the largest retainer, then look at the path to the nearest GC root. That path is the answer, because it names the field that is holding on.

The shape you find most often is depressingly simple:

import java.util.HashMap;
import java.util.Map;

final class SessionCache {
    // Static, so it is reachable from a GC root for the life of the process,
    // and unbounded, so the live set grows with cumulative traffic rather than
    // with concurrent traffic. Nothing here is a bug the compiler can see.
    private static final Map<String, Session> CACHE = new HashMap<>();

    static void remember(String id, Session session) {
        CACHE.put(id, session);
    }
}

Other recurring shapes are a ThreadLocal never cleared on a pooled thread, a listener registered and never removed, and a classloader kept alive by one stray reference, which manifests as a Metaspace error after several redeploys rather than as a heap error.

The claim that separates a strong answer

Weak answers treat the heap dump as the whole investigation. Strong answers know that a heap dump is a single instant and therefore cannot, on its own, distinguish a leak from a large-but-correct working set: a dump taken at the moment of failure shows a full heap either way. Growth over time is what proves a leak, and that comes from the GC log or from repeated histograms. If you have neither, JDK Flight Recorder's old-object sampling gives you allocation stack traces for objects that survived, which tells you where the retained memory was created — usually the fastest route from symptom to line of code.

The second differentiator is knowing that not every out-of-memory event is an OutOfMemoryError. In a container, the kernel's OOM killer terminating the process leaves no Java stack trace at all, and the fix there is often the opposite of raising -Xmx: the JVM reads the cgroup limit and sizes a default heap as a fraction of it, but thread stacks, Metaspace, code cache, direct buffers and native allocations all live outside that heap and still count against the container limit.

Likely follow-ups

  • How would you tell a Java OutOfMemoryError apart from the container being killed by the kernel?
  • What does a Metaspace OutOfMemoryError usually mean, and why do repeated redeploys trigger it?
  • Why can shrinking the heap fix "unable to create new native thread"?
  • How does the default maximum heap get chosen when you run in a container with no -Xmx?

Related questions

Further reading

jvmgarbage-collectionheap-dumpmemory-leaktroubleshooting