Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Every OutOfMemoryError and What It Means

Each OutOfMemoryError message, what it actually indicates, the most likely cause, and the first three things to check — plus why OOMKilled by the kernel is a different failure entirely.

Advanced6 min readUpdated
On this page

OutOfMemoryError is not one error. The message after the colon tells you which region ran out, and each region has a different set of causes and a different first thing to check.

Key Takeaways

  • The message names the region. Java heap space, Metaspace, Direct buffer memory and unable to create native thread are four unrelated problems.
  • GC overhead limit exceeded is heap exhaustion with an earlier warning.
  • unable to create native thread is usually an OS or thread-count limit, not a heap problem — and lowering -Xmx can fix it.
  • OOMKilled (exit 137) is the kernel, not the JVM. No stack trace, no heap dump.
  • Always run production with -XX:+HeapDumpOnOutOfMemoryError.

The field guide

MessageRegionMost likely cause
Java heap spaceHeapLeak, undersized heap, or one huge allocation
GC overhead limit exceededHeapSame, caught slightly earlier
Requested array size exceeds VM limitHeapAn array over ~2 billion elements
MetaspaceNativeClassloader leak, or heavy dynamic class generation
Compressed class spaceNativeOver 1GB of class metadata with compressed oops
unable to create native threadNativeOS thread limit, or no address space left for stacks
Direct buffer memoryNativeUnpooled NIO buffers, or MaxDirectMemorySize too low
reason stack_trace_with_native_methodNativeA JNI or native library allocation failed

Java heap space

what it looks like
java.lang.OutOfMemoryError: Java heap space
    at java.base/java.util.Arrays.copyOf(Arrays.java:3745)
    at java.base/java.lang.StringBuilder.append(StringBuilder.java:172)
    at com.acme.ReportBuilder.render(ReportBuilder.java:88)

The stack trace shows where the allocation failed, which is rarely where the memory went. A StringBuilder.append at the top usually means the heap was already nearly full and this happened to be the next request.

Three causes, distinguished by the GC log:

A leak — occupancy after each Full GC climbs. Go to The seven classic memory leaks.

An undersized heap — occupancy after Full GC is stable but high. The working set genuinely does not fit. Raise -Xmx, or reduce what is held live.

One large allocation — the heap was fine and a single request asked for too much. Loading a 200MB CSV with Files.readAllBytes, a query with no LIMIT, an unbounded IN clause. The fix is streaming and pagination, not a bigger heap.

the pattern that causes it
// Materialises every row. One request can exhaust the heap.
List<Order> all = repository.findAll();
 
// Streams, with a fetch size, releasing each row after processing.
try (Stream<Order> stream = repository.streamAll()) {
    stream.forEach(this::process);
}

GC overhead limit exceeded

Thrown when the JVM has spent over 98% of recent wall-clock time collecting and recovered under 2% of the heap. It is a mercy: without it, the application would simply stop responding while technically still running, which is far harder to diagnose than a crash.

Treat it identically to Java heap space. Do not disable it with -XX:-UseGCOverheadLimit — that converts an immediate, diagnosable failure into a process that consumes a whole CPU core and serves nothing.

Metaspace

OutOfMemoryError: Metaspace
 

Class metadata exhausted. Two realistic causes:

A classloader leak. Common in application servers that redeploy, and in anything that creates classloaders at runtime. Each redeploy adds a full copy of the application's classes, retained by one stray reference.

Dynamic class generation. Heavy proxying (CGLIB, ByteBuddy), scripting engines, and some serialisation and mocking libraries generate classes at runtime. A cache-per-instance rather than cache-per-class bug produces an unbounded stream of generated classes.

diagnosis
jcmd <pid> VM.metaspace summary
jcmd <pid> GC.class_stats                    # needs -XX:+UnlockDiagnosticVMOptions
-Xlog:class+load=info                        # what is being loaded, and by which loader
-XX:MaxMetaspaceSize=512m                    # bound it so it fails fast and diagnosably

If the loaded-class count grows without bound over hours, it is one of the two causes above.

unable to create native thread

OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
 

This is the counterintuitive one: it is usually not a heap problem, and raising -Xmx can make it worse, because a larger heap leaves less address space for thread stacks.

what to check, in order
# 1. How many threads does the process have?
ls /proc/<pid>/task | wc -l
 
# 2. What is the OS limit?
ulimit -u                          # max user processes
cat /proc/sys/kernel/threads-max
cat /sys/fs/cgroup/pids.max        # containers have their own PID limit
 
# 3. Where are they coming from?
jcmd <pid> Thread.print | grep '^"' | sed 's/".*//' | sort | uniq -c | sort -rn

That last command groups thread names, and the answer is almost always visible immediately: a pool created per request, an HTTP client instantiated in a loop, a newCachedThreadPool under a traffic spike, or a scheduler that is never shut down.

Fixes: bound the thread creation (the real fix); reduce -Xss from 1MB to 512k or 256k to fit more threads; raise the OS or cgroup limit; or move to virtual threads.

Direct buffer memory

OutOfMemoryError: Direct buffer memory
 

Off-heap NIO buffers exhausted. -XX:MaxDirectMemorySize defaults to the value of -Xmx, so a 4GB heap silently permits another 4GB of direct memory.

Direct buffers are freed only when their Java wrapper is garbage collected, so if the heap is not under pressure the collector has no reason to run and native memory keeps growing. Netty's pooled allocator exists precisely for this; the usual application-level bug is allocating a direct buffer per request instead of pooling.

see the direct-buffer usage
jcmd <pid> VM.native_memory summary       # requires -XX:NativeMemoryTracking=summary
# Also exposed as a JMX bean: java.nio:type=BufferPool,name=direct

OOMKilled — a different failure

what you see instead
$ kubectl describe pod api-7f8d
    Last State:  Terminated
      Reason:    OOMKilled
      Exit Code: 137

No Java stack trace, no heap dump, no application log line — the kernel sent SIGKILL. The JVM never knew it was in trouble, which means its own limits were never reached: the process exceeded the cgroup limit while the heap was fine.

The cause is almost always non-heap memory being unaccounted for. Container limit 2GB and -Xmx2g leaves nothing for Metaspace, thread stacks, the code cache, direct buffers and GC structures — all of which are covered in JVM memory areas.

the fix
# Let the JVM read the container limit and take a percentage of it
-XX:MaxRAMPercentage=70.0
-XX:MaxMetaspaceSize=256m
-XX:MaxDirectMemorySize=256m
-XX:NativeMemoryTracking=summary

Details in JVM flags and container limits.

Always be ready

the production defaults
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heapdump.hprof
-XX:+ExitOnOutOfMemoryError          # or CrashOnOutOfMemoryError
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime:filecount=5,filesize=20M

The heap dump costs nothing until it fires, and without it you are guessing. Note the path must be on a volume that survives the container, and the disk must have room for a file the size of the heap.

ExitOnOutOfMemoryError is worth arguing for: after an OutOfMemoryError, a JVM may keep running with some threads dead and some objects half-initialised, serving errors while passing health checks. Dying immediately and letting the orchestrator restart the pod is usually the better outcome. In Kubernetes, pair it with a heap dump written to a persistent volume so the evidence outlives the pod.

What gets asked

"What types of OutOfMemoryError are there?" is common, and naming four or five with distinct causes is a good answer. The stronger question is scenario-based: "your pod restarts with exit code 137 but there is nothing in the logs" — the answer is OOMKilled, non-heap memory unaccounted for, and the fix is MaxRAMPercentage plus bounding Metaspace and direct memory.

Frequently Asked Questions

Should you ever catch OutOfMemoryError?
Almost never. It is an Error, not an Exception, and by the time it is thrown the JVM is in a state where any allocation may fail — including inside your handler. The two defensible cases are a top-level handler that logs and initiates an orderly shutdown, and a worker that catches it around a single known-large allocation it can degrade. Catching it and continuing normally leaves the process in an undefined state.
What is the difference between OutOfMemoryError and OOMKilled?
OutOfMemoryError is thrown by the JVM when it cannot satisfy an allocation within its own limits, and it produces a stack trace and can trigger a heap dump. OOMKilled is the Linux kernel sending SIGKILL because the process exceeded its cgroup memory limit — there is no stack trace, no heap dump, no log line, and exit code 137. The second usually means non-heap memory was not accounted for when sizing the container.
What causes GC overhead limit exceeded?
The JVM spending more than 98 percent of recent time in garbage collection while recovering less than 2 percent of the heap. It means the heap is effectively full and collection is achieving nothing, so it is a form of early warning before a hard heap-space failure. It is almost always a leak or a genuinely undersized heap, and disabling the check with UseGCOverheadLimit only replaces a clear failure with a hang.

Related tutorials