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 {
private static final Map<String, Session> CACHE = <>();
{
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.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).