The 6 JVM OutOfMemoryErrors and how to read each one
java.lang.OutOfMemoryError is not a problem, it’s a family of problems. What matters is the text after the colon. Java heap space and unable to create native thread don’t share a cause, aren’t seen with the same tool, and aren’t fixed in the same place. Raising -Xmx solves the first and does nothing for the second.
This article goes through the six messages HotSpot produces in practice. For each one: what the JVM was trying to do, why it couldn’t, and where to start. Every excerpt comes from Temurin 25.0.4 on Linux, inside a container, using programs of a few lines that trigger the error on purpose.
The starting point is a simple reminder. The JVM has several memory areas, each with its own limit. The heap for objects, Metaspace for classes, direct memory for NIO buffers, one stack per thread. Each OutOfMemoryError message names one of those areas. Once you know which one, the diagnosis is already half done. If what lives outside the heap isn’t clear to you, Off-heap memory: where the JVM’s RAM goes spells it out.
1. Java heap space
The famous one. The JVM wanted to allocate an object in the heap, ran a full collection to make room, and still didn’t have enough room:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at HeapSpace.main(HeapSpace.java:8)
The program behind it is a few lines long, started with -Xmx64m:
import java.util.ArrayList;
import java.util.List;
public class HeapSpace {
public static void main(String[] args) {
List<byte[]> retained = new ArrayList<>();
while (true) {
retained.add(new byte[1024 * 1024]);
}
}
}
What makes the error readable isn’t the stack trace, it’s the GC log right before it. With -Xlog:gc, you can watch the JVM struggle:
[0.038s][info][gc] GC(7) Pause Young (Normal) (G1 Humongous Allocation) 63M->63M(64M) 0.461ms
[0.040s][info][gc] GC(8) Pause Full (G1 Compaction Pause) 63M->63M(64M) 1.693ms
[0.042s][info][gc] GC(9) Pause Full (G1 Compaction Pause) 63M->63M(64M) 1.975ms
java.lang.OutOfMemoryError: Java heap space
63M->63M on a Full GC is the signature. The collection ran and freed nothing: everything in the heap is still referenced. From there, only two cases. Either the heap is too small for the real load, and you can tell because the error shows up at peak traffic and then goes away. Or one object is holding on to everything else, the heap climbs week after week, and it’s a leak.
The reflex to have before you even start looking is to set two flags in production, once and for all:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps
The JVM then writes a heap dump at the exact moment of the error, while the guilty object is still there:
java.lang.OutOfMemoryError: Java heap space
Dumping heap to /tmp/heap.hprof ...
Heap dump file created [36297923 bytes in 0.051 secs]
Analysing the dump is a topic of its own: Diagnosing a memory leak with a heap dump. And to tell a spike from a leak by reading the logs without any tool, Reading GC logs without an external tool.
2. GC overhead limit exceeded
This one is surprising, because the heap isn’t quite full. The JVM decided to give up early: it’s spending more than 98% of its time collecting and recovering less than 2% of the heap each time. Going on would make no sense, so it throws:
[0.589s][info][gc] GC(111) Pause Full (Allocation Failure) 59M->59M(62M) 7.321ms
[0.597s][info][gc] GC(112) Pause Full (Allocation Failure) 59M->59M(62M) 7.980ms
[0.608s][info][gc] GC(113) Pause Full (Allocation Failure) 59M->59M(62M) 10.785ms
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
at GcOverhead.main(GcOverhead.java:16)
The program, started with -XX:+UseParallelGC -Xmx64m and 4096 as argument:
import java.util.HashMap;
import java.util.Map;
public class GcOverhead {
public static void main(String[] args) {
// Garbage size taken from the command line: with a constant,
// the JIT removes the dead allocation and the demo ends in Java heap space.
int garbage = Integer.parseInt(args[0]);
Map<Integer, byte[]> cache = new HashMap<>();
int i = 0;
while (true) {
// A small object kept forever, next to a bigger one dropped at
// once: the heap fills slowly and each collection frees only
// the garbage of the last iteration.
cache.put(i++, new byte[256]);
byte[] tmp = new byte[garbage];
tmp[0] = 1;
}
}
}
It keeps one small object per iteration in a HashMap, and allocates a 4 KB array next to it that’s dropped at once. The heap fills slowly, and each Full GC only recovers the garbage of the last iteration. Here, over a hundred Full GCs of 7 to 11 ms, a few milliseconds apart, without the heap ever going down in the log. The JVM eventually calls it.
A detail few people know: this message only exists with the Parallel GC. The same program, with the same 64 MB, under G1 and under Serial gives a regular Java heap space. I checked all three side by side. Since G1 has been the default collector since Java 9, this message is getting rarer, except on batch applications that kept -XX:+UseParallelGC.
Underneath, it’s the same thing as case 1, only slower. Same diagnosis, same tools, same heap dump. The -XX:-UseGCOverheadLimit flag silences the message, but gains nothing. With the flag, the same program went through 173 Full GCs instead of 113 before ending in Java heap space. Don’t use it: it delays the same error, with a frozen application in the meantime.
3. Metaspace
Here the objects are fine. It’s the classes that no longer fit. Metaspace stores the metadata of every loaded class, and it grows when classes get loaded and never unloaded:
[0.118s][info][gc] GC(8) Pause Full (Metadata GC Clear Soft References) 6M->6M(20M) 5.106ms
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
at java.base/java.lang.ClassLoader.defineClass1(Native Method)
at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:962)
at Meta$Loader.define(Meta.java:9)
at Meta.main(Meta.java:22)
The log line says it all: the JVM attempted a special collection, Metadata GC Clear Soft References, to unload classes, and couldn’t unload any. The program, started with -XX:MaxMetaspaceSize=32m:
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
public class Meta {
static class Loader extends ClassLoader {
private final byte[] bytes;
Loader(byte[] bytes) { super(null); this.bytes = bytes; }
Class<?> define() { return defineClass("Meta$Payload", bytes, 0, bytes.length); }
}
static class Payload { int a; int b; int c; }
public static void main(String[] args) throws Exception {
byte[] bytes;
try (InputStream in = Meta.class.getResourceAsStream("Meta$Payload.class")) {
bytes = in.readAllBytes();
}
List<Class<?>> classes = new ArrayList<>();
while (true) {
// A new loader each time: the class is defined again and again,
// and the list keeps every loader alive.
classes.add(new Loader(bytes).define());
}
}
}
It creates a fresh ClassLoader on every iteration, defines the same class with it again, and keeps every loader in a list. As long as a loader is referenced, so are its classes.
In real life the cause is almost always one of these three:
- Hot redeployments on an application server, where the old version stays pinned by a thread or a
ThreadLocal. - Runtime class generation: proxies, serialized lambdas, scripting engines, dynamic mapping. A library that generates one class per request instead of one per type.
- A cache of
ClassLoaderorClassobjects growing without bound.
One detail changes everything in a container. Metaspace has no limit by default. Without -XX:MaxMetaspaceSize, it never throws this error: it pushes the RSS up to the container limit, and the pod dies OOMKilled without a word. Setting a reasonable limit fixes nothing, but it turns a silent death into an error with a stack trace:
-XX:MaxMetaspaceSize=256m
For the diagnosis, a heap dump works too: ClassLoader instances are objects, and their count in the dump gives the leak away. jcmd <pid> VM.classloader_stats gives the same information live, without a dump.
There’s a sibling: OutOfMemoryError: Compressed class space. That’s the area, reserved next to Metaspace, holding the part of the metadata addressed through compressed pointers. Its size is fixed at startup, 1 GB by default, adjustable with -XX:CompressedClassSpaceSize. Same cause, same cure as Metaspace.
4. Cannot reserve N bytes of direct buffer memory
NIO’s direct buffers live outside the heap, in an area with its own limit. When it’s full, the message has the merit of giving the numbers:
Exception in thread "main" java.lang.OutOfMemoryError: Cannot reserve 1048576 bytes of direct buffer memory (allocated: 67108864, limit: 67108864)
at java.base/java.nio.Bits.reserveMemory(Bits.java:178)
at java.base/java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:108)
at java.base/java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:367)
at Direct.main(Direct.java:9)
The program, started with -XX:MaxDirectMemorySize=64m:
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
public class Direct {
public static void main(String[] args) {
List<ByteBuffer> buffers = new ArrayList<>();
while (true) {
buffers.add(ByteBuffer.allocateDirect(1024 * 1024));
}
}
}
On Java 11, the same message just says Direct buffer memory, with no numbers. Since Java 17, you read straight away how much is allocated and what the limit is. That limit is set with -XX:MaxDirectMemorySize, and by default it equals the maximum heap size. On an application with -Xmx4g, there are potentially 4 GB of direct buffers next to the 4 GB of heap. In a container, that deserves an explicit value.
The trap with this message is that it often shows up when direct memory isn’t really in use. A DirectByteBuffer gives its native memory back when the Java object is collected by the GC. With a large heap and rare collections, dead buffers pile up waiting for a collection that never comes. The JVM does attempt a System.gc() before throwing, but if you’ve set -XX:+DisableExplicitGC, that call does nothing, and the error lands with a half-empty heap. I checked: a program that allocates 2,000 buffers of 1 MB without keeping them, with a 64 MB limit, passes without the flag and fails with it.
Two leads for the diagnosis. The BufferPoolMXBean named direct, exposed through JMX and by most metrics agents, gives the usage curve. And if Netty is in the application, its own allocator has its own metrics and its own leaks, usually a ByteBuf that’s never released. The details of the off-heap areas are in Off-heap memory.
One last point, and it matters for what follows: this error is thrown by Java code, in Bits.reserveMemory, not by the virtual machine itself. We’ll see below why that changes how -XX:+ExitOnOutOfMemoryError behaves.
5. Unable to create native thread
The JVM asked the operating system for a thread, and the system refused. The full message is cautious, because the JVM doesn’t know why:
[0.059s][warning][os,thread] Failed to start thread "Unknown thread" - pthread_create failed (EAGAIN) for attributes: stacksize: 512k, guardsize: 0k, detached.
[0.060s][warning][os,thread] Failed to start the native thread for java.lang.Thread "Thread-279"
Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
at java.base/java.lang.Thread.start0(Native Method)
at java.base/java.lang.Thread.start(Thread.java:1417)
at Threads.main(Threads.java:8)
It’s not a lack of memory, despite the class name. The program creates sleeping threads:
public class Threads {
public static void main(String[] args) {
int count = 0;
while (true) {
Thread t = new Thread(() -> {
try { Thread.sleep(Long.MAX_VALUE); } catch (InterruptedException e) { }
});
t.setDaemon(true); t.start();
count++;
if (count % 100 == 0) System.out.println(count + " threads");
}
}
}
It runs with -Xss512k, in a container started with --pids-limit=300. The refused thread is called Thread-279, the application’s 280th. With the JVM’s internal threads, that reaches the limit of 300, and pthread_create returns EAGAIN. The warning line just above is more useful than the exception: it gives the error code and the requested stack size.
The causes, by frequency:
- The cgroup process limit. On Kubernetes that’s
pids.max, often set by the runtime without anyone knowing. A thread counts as a process. - The
ulimit -uof the user running the JVM. - A real lack of memory, when each 1 MB stack eventually finds no room in the address space or in the container limit.
In every case the question is the same: why so many threads? A thread dump answers it, with the name and stack of each one. A pool created per request and never shut down is spotted in ten seconds: Reading a thread dump with jstack. And if the application really needs thousands of waiting threads, virtual threads don’t each get a native thread: Virtual threads in production.
6. Requested array size exceeds VM limit
The only one of the six that doesn’t depend on available memory:
Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit
at ArraySize.main(ArraySize.java:3)
The program:
public class ArraySize {
public static void main(String[] args) {
int[] big = new int[Integer.MAX_VALUE];
System.out.println(big.length);
}
}
The JVM refuses before even looking at the heap: I ran it with -Xmx64m and with -Xmx8g, same error. And the limit is very narrow. Only the last two lengths, Integer.MAX_VALUE and Integer.MAX_VALUE - 1, are refused, for an int[] as well as a byte[]. An array of Integer.MAX_VALUE - 2 passes the check, and then the heap decides, with a Java heap space if it’s too small.
In production you get there with Integer.MAX_VALUE used to mean “unlimited”: new StringBuilder(Integer.MAX_VALUE), ByteBuffer.allocate(Integer.MAX_VALUE), or a computed size that lands exactly on it. The first two produce this exact message. On Java 8, ArrayList also asked for an array of Integer.MAX_VALUE elements as a last resort while growing, and hit it.
Collections on recent JDKs stop earlier. ArrayList, StringBuilder and ByteArrayOutputStream cap their growth at Integer.MAX_VALUE - 8, and when even that isn’t enough, the error comes from Java code, with a different wording:
Exception in thread "main" java.lang.OutOfMemoryError: Required array length 2147483631 + 100 is too large
at java.base/jdk.internal.util.ArraysSupport.hugeLength(ArraysSupport.java:914)
at java.base/jdk.internal.util.ArraysSupport.newLength(ArraysSupport.java:907)
at java.base/java.lang.AbstractStringBuilder.newCapacity(AbstractStringBuilder.java:344)
The code that produces it, with -Xmx3g:
StringBuilder sb = new StringBuilder(Integer.MAX_VALUE - 16);
sb.setLength(Integer.MAX_VALUE - 16);
sb.append("x".repeat(100));
A StringBuilder filled with 2,147,483,631 characters receives 100 more. The real-life case is an HTTP response accumulated without end, a multi-gigabyte file read whole into a ByteArrayOutputStream, a SQL query with no LIMIT. In practice, with a normally sized heap, those collections throw Java heap space long before reaching 2 billion elements. All three messages therefore point at the same bug. If the stack trace shows Arrays.copyOf, grow or newLength, look for the collection growing without bound, not for a leak.
What looks like an OutOfMemoryError and isn’t one
Two out-of-memory deaths never go through a Java exception. They’re more common than half of the cases above, and harder to read because there’s no stack trace at all.
The container gets killed. The process exceeds the cgroup memory limit, the kernel kills it with a SIGKILL. The JVM has no time to say anything. All that’s left is the exit code:
$ docker inspect app --format 'ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}'
ExitCode=137 OOMKilled=true
Here the JVM was running with -Xmx512m in a container limited to 128 MB. On Kubernetes, kubectl describe pod shows Reason: OOMKilled on the container’s last state. The cause is almost always the same: heap plus the rest of the JVM exceeds the limit, because someone sized the heap without counting the rest. Tuning the JVM in a container explains how to leave the margin.
The JVM stops with an hs_err file. When it’s the JVM itself that can’t get memory from the system, to grow the heap for instance, it can’t throw an exception. It writes a report and stops:
OpenJDK 64-Bit Server VM warning: INFO: os::commit_memory(0x0000000740000000, 3221225472, 0) failed; error='Not enough space' (errno=12)
#
# There is insufficient memory for the Java Runtime Environment to continue.
# Native memory allocation (mmap) failed to map 3221225472 bytes. Error detail: committing reserved memory.
# An error report file with more information is saved as:
# /tmp/hs_err_pid269.log
This case was reproduced on a VM with vm.overcommit_memory=2, where the kernel refuses to commit more memory than it has. The hs_err_pid<pid>.log file lists the possible causes, including the threads and their stacks. It also contains the memory state, the thread list and the JVM flags. Read it, that’s what it’s for.
The flags that react, and the ones that don’t
Three flags exist for that moment. -XX:+HeapDumpOnOutOfMemoryError writes a dump. -XX:+ExitOnOutOfMemoryError stops the JVM, so the orchestrator restarts a healthy process instead of leaving a zombie alive. -XX:+CrashOnOutOfMemoryError does the same while producing an hs_err, and a core dump if the system allows it.
What the documentation doesn’t say clearly is that these flags don’t react to every message. They’re wired into the virtual machine, on the path that throws the error for the heap and Metaspace. An error thrown by Java code goes right past them. I tested each case with -XX:+ExitOnOutOfMemoryError and a catch (OutOfMemoryError):
| Message | Does the JVM exit? |
|---|---|
| Java heap space | yes, Terminating due to java.lang.OutOfMemoryError: Java heap space |
| GC overhead limit exceeded | yes |
| Metaspace | yes |
| Requested array size exceeds VM limit | yes |
| Required array length N + M is too large | no, the app keeps running |
| Cannot reserve N bytes of direct buffer memory | no, the app keeps running |
| Unable to create native thread | no, the app keeps running |
For the errors thrown by Java code, the test output left no doubt:
caught: java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
still running after the error
That’s the worst-case scenario in production. A thread pool catches the error, logs it, and carries on with one thread fewer. The app stays alive, responds worse and worse, and the healthcheck stays green. For those messages you need an alert on the log or on a metric, not a JVM flag.
Even without the flags, remember that a caught OutOfMemoryError has cleaned up nothing. The test program catches it and prints still running after the error in all six cases. What survived is a JVM where nobody knows which allocations failed halfway through. The right reflex is still to exit and let it restart.
In short
Read the text after OutOfMemoryError:, it names the area. Java heap space and GC overhead limit exceeded are about the heap: heap dump, and tell the traffic spike from the leak. Metaspace is about classes: look for ClassLoader instances and set a limit so the error becomes readable. Direct buffer memory is about NIO buffers, often dead and waiting for a GC. Unable to create native thread is about a process limit, not memory: count the threads. Requested array size is about a collection that grew without bound. And if the process dies with no exception, look at exit code 137 or the hs_err file. Finally, set -XX:+HeapDumpOnOutOfMemoryError and -XX:+ExitOnOutOfMemoryError everywhere, knowing they only cover four messages out of six.