Skip to content

Java OutOfMemoryError: Every Message and What It Means

Java heap space, Metaspace, GC overhead, direct buffer, native thread: every OutOfMemoryError message, the memory area behind it, and where to look first.

java-memory-management outofmemoryerror troubleshooting
Gopi Gorantala
Reading Progress

On This Page

The most useless OutOfMemoryError I've ever been handed was this one:

Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"

No detail message. No stack trace. Nothing after the colon to tell you which of the several different things called a Java OutOfMemoryError just happened. I got it on purpose, in a lab, on JDK 21.0.12.1 with -Xmx64m, and the section on the bystander thread below shows how. In production you get it at 3 a.m. and someone pastes it into the incident channel with "heap?".

Maybe. Maybe not. OutOfMemoryError is one class with at least eight different jobs, and the text after the colon is the only thing that says which job. Heap, Metaspace, thread stacks, direct buffers and the kernel all run out in their own way, and each wants a different fix. This article is the map. Every message I could reproduce is below with the output that came out of my run, and every one links to the article that goes the full distance.

Find out what actually failed, message by message

Read the text after java.lang.OutOfMemoryError:. That's the whole triage. Here's the table I keep open during an incident. "Verified" means I made the JVM print that exact string this session; the rest I'm taking from the HotSpot source and the Troubleshooting Guide, and I've marked them.

Message What ran out Who threw it First thing I check Deep dive
Java heap space Java heap, full of live objects JVM, at allocation Heap after GC over time: flat, climbing, or spiking? heap space
GC overhead limit exceeded Heap again, but the JVM gave up early JVM (Parallel GC in my run) Same as above, plus which collector you run overhead limit
Metaspace Class metadata (native memory) JVM Loaded class count over time; redeploys Metaspace
Compressed class space The slice of class memory that compressed class pointers need JVM jcmd <pid> VM.metaspace class space
Requested array size exceeds VM limit Nothing. The array length is over the maximum JVM Where one array grows by doubling array limit
unable to create native thread: possibly out of memory or process/resource limits reached OS threads, pids, or stack address space JVM, after pthread_create fails Thread count vs ulimit -u and the cgroup pids limit native thread
Cannot reserve N bytes of direct buffer memory (allocated: A, limit: L) The MaxDirectMemorySize budget JDK library (java.nio.Bits) BufferPoolMXBean usage direct buffers
Map failed (wording varies; not reproduced here) Memory-mapped regions JDK library vm.max_map_count mmap
Out of swap space? / "insufficient memory" (not reproduced here) Native malloc or mmap failed JVM, usually as a fatal crash with an hs_err file The hs_err_pid file header native alloc failure

One more entry that isn't in the table because it isn't Java's at all: exit code 137 and a pod showing OOMKilled. There's no exception, no stack trace, no heap dump, because the JVM never got a say. The kernel killed the process. That's a different article, and this comparison is how to tell the two apart quickly.

Notice who threw each one. Most come from the JVM, but the direct buffer one is thrown by plain Java code in java.nio.Bits.reserveMemory, and my trace shows it. That matters when you hunt for a cause, because the JVM didn't hit a wall at all. A counter in a library crossed a configured limit.

Two commands narrow things down further, and both need the JVM alive:

jcmd <pid> GC.heap_info                    # per-space heap occupancy
jcmd <pid> Thread.print | grep -c '^"'     # rough live thread count

If you're past the point of having a live JVM, the message in the log is what you've got. Which is why the next section matters.

Break it on purpose

I wrote one small program with a mode switch and ran each mode in its own JVM, so every message below is real output. The setup: OpenJDK 21.0.12.1 on x86_64 Linux, 2 cores, 8 GB, no container (I had no Docker daemon and no Kubernetes available, so nothing here is container-specific). All of it is bounded with small limits. Run it that way too, never on a shared box, and put the thread one in a throwaway container.

import java.nio.ByteBuffer;
import java.util.*;

public class OomLab {
    static final List<Object> keep = new ArrayList<>();
    public static void main(String[] a) throws Exception {
        switch (a[0]) {
            case "heap"    -> { while (true) keep.add(new byte[1024 * 1024]); }          // live data, GC can't reclaim it
            case "direct"  -> { while (true) keep.add(ByteBuffer.allocateDirect(1024 * 1024)); }
            case "threads" -> { while (true) { Thread t = new Thread(() -> { try { Thread.sleep(600_000); } catch (InterruptedException e) {} }); t.start(); keep.add(t); } }
            case "maxarr"  -> {
                for (int n : new int[]{Integer.MAX_VALUE, Integer.MAX_VALUE - 1, Integer.MAX_VALUE - 2}) {
                    try { byte[] b = new byte[n]; System.out.println(n + " ok"); } catch (Throwable t) { System.out.println(n + " -> " + t); }
                }
            }
        }
    }
}

(The meta mode defines empty classes through a fresh class loader in a loop and keeps them reachable. It's about forty lines of hand-built class-file bytes, so I've left it out; any class-generating loop does the same job.)

Commands and what they printed:

$ java -Xmx64m OomLab direct   (with -XX:MaxDirectMemorySize=16m)
Exception in thread "main" java.lang.OutOfMemoryError: Cannot reserve 1048576 bytes of direct buffer memory (allocated: 16777216, limit: 16777216)
	at java.base/java.nio.Bits.reserveMemory(Bits.java:178)

$ java -XX:MaxMetaspaceSize=16m OomLab meta
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
	at java.base/java.lang.ClassLoader.defineClass1(Native Method)

$ java -XX:MaxMetaspaceSize=256m -XX:CompressedClassSpaceSize=1m OomLab meta
Exception in thread "main" java.lang.OutOfMemoryError: Compressed class space

$ java -Xmx64m -XX:+UseParallelGC OomLab overhead     (a HashMap of tiny entries)
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded

$ java -Xmx64m -XX:+UseG1GC OomLab overhead           (same code, different collector)
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

That last pair is the first surprise, and I'll come back to it. The thread one needs a user the kernel will actually limit (root bypasses RLIMIT_NPROC), so I ran it as nobody with ulimit -u 200 inside a subshell:

[0.055s][warning][os,thread] Failed to start thread "Unknown thread" - pthread_create failed (EAGAIN) for attributes: stacksize: 256k, guardsize: 0k, detached.
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)

Heap was nowhere near full there. About 180 threads were all it took under that limit. And afterwards the JVM did not exit. The 180 sleeping non-daemon threads kept it alive with main already dead, and I had to kill -9 it (my timeout -s KILL did, hence the 137 in my own log, which is the shell's, not a container's). Keep that in mind. An OOM in one thread doesn't end a process.

Teardown: nothing in that set leaves files except heap dumps, if you add -XX:+HeapDumpOnOutOfMemoryError. A 64 MB heap gave me a 68,643,638-byte dump in 0.05 seconds, which is cute at lab scale and a different story at 14 GB.

What the message is really telling you

The detail message says which resource hit its limit. It doesn't say why. And it doesn't say that the thing that threw is the thing that caused it.

That last part bit me in the lab. I set up one thread that hoards 512 KB arrays and another that does trivial work with the odd string. With -Xmx64m, the first line of output was java.lang.OutOfMemoryError: Java heap space from the heap-dump hook, then two lines saying the OOM was "thrown from the UncaughtExceptionHandler" in the thread named bystander and in leaker. The order of the two lines flipped between runs. In all three runs both threads got the error, the guilty one and the innocent one, because any thread that asks for memory at that moment gets it.

Back to that mystery line at the top. The default handler tried to print a stack trace, which allocates, in a heap that was already full, so it failed too. What you get is the one-line message with nothing in it. I didn't take this further than the observation. When the same program caught the error itself with a little headroom left, it printed a normal one-frame trace, so the handler failing is about headroom at that instant. When you see a detail-free OOM, look for -XX:+HeapDumpOnOutOfMemoryError output or GC logs, because the log line won't help.

How the JVM ends up throwing a Java OutOfMemoryError

I went looking for the throw site because I wanted to know how often a full heap just means "full". In memAllocator.cpp, MemAllocator::Allocation::check_out_of_memory picks the string from one flag: "GC overhead limit exceeded" if the overhead flag was set, "Java heap space" otherwise, then throws a pre-built exception object from Universe (the source calls accessors like out_of_memory_error_java_heap(); I read this at the jdk-21+35 tag, and I'm inferring "pre-built" from the accessor names and from the zero-frame stack I measured on the bystander thread). That explains the stackless errors. The JVM can't count on having memory to build a trace in.

What leads up to that line is the allocation path: the thread's allocation buffer is full, the heap can't expand, the JVM runs collections and retries, and only then does it give up. Heap layout and collectors own that story, and the JVM series covers it. The piece I care about here is that heap OOM is a result, not a diagnosis.

Then the overhead limit. Same Parallel-versus-G1 code, two different messages. GC overhead limit exceeded showed up with -XX:+UseParallelGC, and G1 on the same JDK and same heap printed plain Java heap space. That's one JDK version on one machine; I did not confirm in source which collectors enforce the limit on JDK 22+ or 25, so don't read it as universal. The full treatment is planned for its own article, and there's an older post on this site that covers heap space and the overhead limit together.

The second surprise was the array one. I'd always said "arrays top out near Integer.MAX_VALUE". Here's what a 2 GB heap actually did:

2147483647 -> java.lang.OutOfMemoryError: Requested array size exceeds VM limit
2147483646 -> java.lang.OutOfMemoryError: Requested array size exceeds VM limit
2147483645 -> java.lang.OutOfMemoryError: Java heap space

Two below Integer.MAX_VALUE is where the limit stops being a limit and goes back to being a heap question. Nothing about free memory is involved in the first two. That one message has nothing to do with the leak you're hunting.

Fixing it properly, and the rule about adding RAM

My rule: don't raise a limit until you've ruled out growth. Raising -Xmx, -XX:MaxMetaspaceSize or -XX:MaxDirectMemorySize gets you a longer time to failure, and sometimes that's exactly the wrong thing, because it makes the failure rarer and harder to reproduce.

The test is the same for every area that holds long-lived data. Graph the resource after the cleaner has run (heap after GC; loaded class count; direct buffer pool usage; live threads) over hours or days. Three shapes:

  • Flat with spikes: the limit is too tight. Raising it is fine.
  • Climbing, never returning: something's holding on. Raising the limit buys time, nothing else.
  • A step that appears and stays after a deploy or a traffic change: you changed the live set, or you leaked once. Compare before and after.

Tools for the climbing case live in the leak articles: heap dumps, setting up the dump itself, and class loader leaks for the Metaspace cases. These URLs go live as the series publishes them.

Fixes that aren't fixes: disabling the overhead check, which turns a clear error into a JVM that spends nearly all its time in GC before dying anyway; -Xss bumps for a thread-count problem (bigger stacks mean fewer threads fit); and a nightly restart as a cure. A restart schedule is a monitoring decision wearing a fix's clothes.

What I'd do instead of tuning limits

Bound everything that can grow, by default. Caches with a maximum size. Queues with a capacity. Thread pools with a fixed ceiling, not newCachedThreadPool in a request path. Streaming instead of reading whole files into memory. One owner for every resource that has to be closed.

And run production with -XX:+HeapDumpOnOutOfMemoryError pointed at a volume that survives the container, and with -XX:+ExitOnOutOfMemoryError so the process dies instead of limping along half-broken. After the bystander experiment I'm even more convinced of the second one. A pool whose workers died of an OOM looks perfectly healthy to a liveness probe that only checks the port. (Exit behaviour for OOMs thrown from library code, like the direct buffer one, I haven't checked, so test yours.)

Which JDK versions behave how

Everything above ran on OpenJDK 21.0.12.1. Things I know changed or that I'd re-check on an upgrade:

  • The direct buffer message in my run reads Cannot reserve 1048576 bytes of direct buffer memory (allocated: 16777216, limit: 16777216). Older JDKs printed just Direct buffer memory. If you grep logs across fleets, match both.
  • The native thread message on 21 ends with possibly out of memory or process/resource limits reached. Search for the prefix.
  • PermGen space is gone since JDK 8. If an old runbook mentions MaxPermSize, the Metaspace flags replaced it.
  • Which collectors enforce the overhead limit: I tested two on one release. Test your own collector on your own JDK.
  • The JDK landscape page I fetched on openjdk.org listed JDK 25 as the latest GA and 26 and 27 as in development, but that page looked stale for an October 2026 date, so I haven't checked 25 or 26 behaviour above.

Screenshot this bit

  • The text after OutOfMemoryError: is the diagnosis. If it's empty, get the heap dump or the GC log.
  • The thread that threw is not necessarily the thread that caused it. Mine killed the innocent one about as often.
  • Requested array size exceeds VM limit is about length, not free memory. Two below Integer.MAX_VALUE it turns into a normal heap error.
  • Graph before you raise. A climbing line after GC is a leak and a bigger limit just delays it.
  • No exception and exit code 137 means the kernel did it, not Java.

What causes java.lang.OutOfMemoryError?
A request for memory that the JVM or a JDK class can't satisfy. The message names the resource: heap, Metaspace, direct buffers, native threads, or an array length over the limit. See the table above.

Is increasing -Xmx a good fix for OutOfMemoryError: Java heap space?
Only if heap after GC is flat and just too close to the limit. If it climbs and doesn't come back down, you have a leak and a bigger heap just delays the error. The heap space article has the triage.

Can I catch an OutOfMemoryError?
You can, but the JVM may not have the memory to run your handler. In my lab, even printing the error failed. Catching is for narrow cases such as rejecting one oversized request; it isn't a recovery plan.

How is OOMKilled different from OutOfMemoryError?
OutOfMemoryError is thrown by the JVM or the JDK and leaves a trace. OOMKilled is the kernel killing the process for exceeding its cgroup memory limit, with exit code 137 and no Java output. See OutOfMemoryError vs OOMKilled.

java-memory-managementoutofmemoryerrortroubleshooting

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments