How do you diagnose an OutOfMemoryError in a production Java service, and why does the specific OOM type change what you look for?
Quick Answer
Enable -XX:+HeapDumpOnOutOfMemoryError before the incident so the JVM writes a .hprof heap dump the moment it dies, then load that dump into Eclipse MAT's Leak Suspects report to find which objects are holding memory. The fix differs by OOM type — heap-space OOMs usually mean unbounded caches or connection leaks, while Metaspace or native-thread OOMs point at classloader leaks or thread-creation storms, not application data at all.
Detailed Answer
An OutOfMemoryError is like a warehouse manager who keeps accepting new inventory shipments but never sends anything back out — eventually every aisle, shelf, and even the loading dock itself is full, and the next truck literally cannot be unloaded anywhere. The JVM's heap is that warehouse: it's a fixed-size region of memory the JVM reserves for your application's objects, and when garbage collection (the process of finding and clearing out objects nothing references anymore, like a crew removing spoiled inventory) can no longer free enough space to satisfy a new allocation, the JVM has no choice but to throw OutOfMemoryError and, usually, kill the request or the whole process.
The reason -XX:+HeapDumpOnOutOfMemoryError matters so much is that OOM is a terrible time to start investigating — the process is either dead or about to be, and by the time someone notices the alert, restarts the pod, and tries to reproduce it, the exact memory state that caused the failure is gone forever. This flag was designed to solve that specific problem: the instant the JVM decides it's out of memory, before it fully unwinds, it dumps the entire heap to a .hprof file on disk, freezing the crime scene exactly as it was at the moment of failure, so you can analyze it hours or days later without needing to reproduce anything live.
Step by step, the diagnostic flow looks like this: first, confirm the flag was actually set (a shocking number of production incidents get zero heap dump because nobody enabled it ahead of time, which is why it should be a default on every JVM service, not an opt-in you remember after the fact). Second, once you have the .hprof file, load it into Eclipse Memory Analyzer (MAT), which builds a full object reference graph and runs a 'Leak Suspects' report — this report works by finding the dominator tree, essentially asking 'if I deleted this one object, how much other memory would become collectible with it,' which surfaces the actual root cause object rather than just the biggest individual object. Third, you classify the OOM type from the error message itself: 'Java heap space' means actual application objects filled the heap; 'Metaspace' means too many classes were loaded (common with dynamic class generation, like some ORM proxies, or classloader leaks in app servers that reload webapps without unloading old classloaders); 'GC overhead limit exceeded' means the JVM is spending over 98% of its time garbage collecting and reclaiming under 2% of the heap each time, meaning it's still technically not 'out' but effectively thrashing; and 'unable to create new native thread' means the OS ran out of memory to allocate new OS-level thread stacks, which is an OS/native memory problem, not a Java heap problem at all.
In production, this gets more complicated inside containers, because the JVM historically read the *host's* total memory rather than the container's cgroup limit, and would size its default heap as a fraction of memory that didn't actually exist inside the container's boundary, guaranteeing an OOM kill by the kernel before the JVM's own heap ever felt pressure. What to monitor: heap usage after each full GC (a steadily rising post-GC floor across many GC cycles is the earliest sign of a genuine leak, as opposed to a temporary spike that GC successfully reclaims), GC pause frequency and duration, and container OOM-kill events from the orchestrator, which are a different failure mode entirely from a JVM-level OutOfMemoryError.
The gotcha: a container getting OOM-killed by the kernel and a JVM throwing its own OutOfMemoryError look similar from the outside (the process dies) but have completely different fixes. If -Xmx is set higher than the container's memory limit, the kernel's cgroup OOM killer terminates the entire JVM process abruptly with SIGKILL — no heap dump, no graceful shutdown, no stack trace, just an exit code 137 — while the JVM itself never got the chance to detect memory pressure and throw its own catchable OutOfMemoryError. Engineers who only tune -Xmx without also setting -XX:MaxRAMPercentage or ensuring -Xmx sits comfortably below the container's cgroup limit end up debugging a phantom 'crash with no logs,' when the real fix was simply giving the JVM enough headroom below the hard container ceiling to fail gracefully instead of being killed abruptly.