An OutOfMemoryError is like a warehouse that keeps filling up; the real trick is finding which room overflowed: heap, metaspace, direct memory, or thread stacks.
Question: Application throwing OutOfMemoryError. How do you investigate?
Answer: First I read the exact error text, because Java heap space, Metaspace, and unable to create new native thread point to different causes. Then I capture evidence with GC logs and a heap dump, reproduce the issue with the same JVM flags, and inspect the dump to find the biggest retainers and their GC roots. If it is not heap-related, I check direct buffers, class loading, or thread count instead of blindly raising -Xmx.
Interview-Ready Answer: I start by checking the exact OOME message and JVM flags, because that tells me whether it is heap, metaspace, direct memory, or native threads. Next I enable heap-dump-on-OOME and GC logging, reproduce the issue, and analyze the dump in a tool like MAT to find the dominant objects and reference chain. If the message points to native memory, I switch to thread dumps, class-loader analysis, or Native Memory Tracking, because the fix depends on the memory area that is actually exhausted.
An OutOfMemoryError means the JVM could not satisfy an allocation request. That does not always mean the Java heap is full; the failure may be in class metadata, off-heap buffers, or native thread memory. So the first job is to identify the exact flavor of the error and collect evidence before the process dies.
Java heap space usually means heap retention or a leak. GC overhead limit exceeded means the JVM is spending too much time collecting and reclaiming too little. Metaspace means class metadata is growing. unable to create new native thread means the OS or process cannot create more threads.-XX:+HeapDumpOnOutOfMemoryError and -XX:HeapDumpPath=/path. For GC logs, use -Xlog:gc* on JDK 9+; on JDK 8 the common flags are -XX:+PrintGCDetails and -XX:+PrintGCDateStamps. Also capture the full JVM flags with -XX:+PrintFlagsFinal or your startup config, because a bad -Xmx or too many threads can be the real cause.Comparison of common OOME messages:
| Message | Usually means | First check |
|---|---|---|
| Java heap space | Heap retention or leak | Heap dump |
| GC overhead limit exceeded | Too much GC, too little reclaim | GC logs + heap dump |
| Metaspace | Class metadata growth | Classloaders |
| Direct buffer memory | Off-heap buffer growth | NMT + buffers |
| unable to create new native thread | Thread or native memory limit | Thread dump + OS limits |
-Xmx in staging to make the failure happen sooner. Keep the same JVM version and GC, because behavior changes between collectors and JDK releases.ThreadLocal misuse, and huge byte[] or char[] buffers.ByteBuffer.allocateDirect without releasing pressure. For threads, remember that each thread consumes native memory plus stack; a common stack size is about 1 MB, so thousands of threads can fail even if heap usage looks fine.-Xmx; that often only hides the leak.Memory hook: ask yourself, “Which shelf is empty, and what keeps refilling it?” That is the JVM memory mindset.
Performance note: heap-dump analysis is roughly proportional to object count, and a multi-GB dump can take minutes to open. Tools like MAT may need several gigabytes of workstation RAM just to inspect a big production dump comfortably.
Real-World Story: A checkout service in a flash sale started returning 500s after a new release. The pods were not immediately dead, but latency climbed, CPU rose, and logs showed repeated full GCs followed by Java heap space. A heap dump in MAT showed one static ConcurrentHashMap inside a promotion cache retaining millions of SKU objects with no TTL. The fix was to cap the cache, add expiration, and move the hot catalog to Redis. The misunderstanding caused a slow-motion outage: requests queued, GC paused the app, and the autoscaler kept adding more pods that all hit the same leak.
The key lesson is that OOME often looks like a performance issue first: timeouts, retries, and GC churn appear before the final crash. If you wait until the last minute to collect a heap dump, you may only see a dying process and miss the real retainer graph.
import java.lang.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryPoolMXBean;
import java.lang.management.MemoryType;
import java.lang.management.MemoryUsage;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
public class OomeInvestigationDemo {
private static final int CHUNK_BYTES = 1024 * 1024; // 1 MB per chunk keeps the demo easy to reason about.
public static void main(String[] args) {
if (args.length > 0 && "help".equalsIgnoreCase(args[0])) {
usage();
return;
}
boolean leak = args.length > 0 && "leak".equalsIgnoreCase(args[0]);
System.out.println("Mode: " + (leak ? "leak" : "transient"));
System.out.println("Tip: run with -Xmx64m leak to make the failure path easier to hit in a lab.");
printSnapshot("start");
List<byte[]> live = new ArrayList<>();
try {
for (int i = 1; i <= 128; i++) {
byte[] block = new byte[CHUNK_BYTES];
block[0] = 1; // Touch the array so the JIT cannot optimize it away.
live.add(block);
if (!leak && live.size() > 4) {
// Non-leak path: drop references so GC can reclaim old blocks.
live.clear();
}
if (i % 16 == 0) {
printSnapshot("after " + i + " MB allocated");
TimeUnit.MILLISECONDS.sleep(100);
}
}
System.out.println("Finished without OOME.");
} catch (OutOfMemoryError oome) {
// Catching OOME is for diagnostics, not for normal recovery.
System.err.println("OutOfMemoryError: " + oome.getMessage());
System.err.println("Production rule: capture evidence, then restart the process.");
printSnapshot("at failure");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("Interrupted.");
}
}
private static void usage() {
System.out.println("Usage:");
System.out.println(" java OomeInvestigationDemo # transient allocations");
System.out.println(" java OomeInvestigationDemo leak # keeps references and grows faster");
System.out.println(" java OomeInvestigationDemo help # show this message");
}
private static void printSnapshot(String phase) {
MemoryMXBean mx = ManagementFactory.getMemoryMXBean();
MemoryUsage heap = mx.getHeapMemoryUsage();
MemoryUsage nonHeap = mx.getNonHeapMemoryUsage();
System.out.println();
System.out.println("=== " + phase + " ===");
System.out.println("Heap used=" + mb(heap.getUsed()) + ", committed=" + mb(heap.getCommitted()) + ", max=" + mb(heap.getMax()));
System.out.println("NonHeap used=" + mb(nonHeap.getUsed()) + ", committed=" + mb(nonHeap.getCommitted()) + ", max=" + mb(nonHeap.getMax()));
for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) {
String name = pool.getName();
if (isInterestingPool(name)) {
MemoryUsage u = pool.getUsage();
if (u != null) {
System.out.println("Pool " + name + " [" + pool.getType() + "] used=" + mb(u.getUsed()) + ", max=" + mb(u.getMax()));
}
}
}
for (BufferPoolMXBean pool : ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)) {
// Direct and mapped buffers are off-heap, so they are useful when heap looks fine but the app still fails.
System.out.println("Buffer " + pool.getName() + " count=" + pool.getCount() + ", used=" + mb(pool.getMemoryUsed()));
}
}
private static boolean isInterestingPool(String name) {
return name.contains("Metaspace")
|| name.contains("Compressed Class Space")
|| name.contains("Code Cache")
|| name.contains("Old")
|| name.contains("Tenured")
|| name.contains("Eden")
|| name.contains("Survivor");
}
private static String mb(long bytes) {
if (bytes < 0) {
return "n/a";
}
return (bytes / (1024 * 1024)) + " MB";
}
}
Follow-up & Tricky Questions:
Metaspace OOME? Check classloader leaks, dynamic proxies, generated classes, and hot reload behavior; a heap dump plus classloader stats usually helps more than just raising heap size.unable to create new native thread? Count live threads, inspect thread dumps, and verify OS limits and stack size, because the heap may be healthy while native memory is exhausted.OutOfMemoryError and keep going? Technically you can catch it, but the JVM may already be in a fragile state. In production the safer move is to log evidence and let the process restart.-Xmx, did I fix it? Not necessarily. You may have only delayed the crash; if the retained set keeps growing, the real bug is still there.GC overhead limit exceeded always mean a leak? No. It means GC is buying very little memory back; the root cause could be a leak, but also an allocation burst or too-small heap.Common Mistakes:
-Xmx. That may postpone the crash but hide the leak; first find the retaining object or memory area.Java heap space and Metaspace need different tools and fixes.Memory Hook: picture the JVM as a city: heap is apartment space, metaspace is the city registry, direct memory is the warehouse, and threads are the roads. The OOME message tells you which district flooded.
Cheat Sheet:
Practice Tasks:
leak and a small heap such as -Xmx64m; note how the snapshot changes before failure.jcmd <pid> GC.heap_info and jcmd <pid> Thread.print on a test app to practice collecting evidence quickly.