OutOfMemoryError: Java heap space and GC overhead limit exceeded: too small or leaking?

"Java heap space" is the OutOfMemoryError everybody has seen, and the one most often fixed with the wrong tool. Doubling -Xmx makes it go away when the heap was simply too small; it only buys time when something is leaking; and it changes nothing when a single request asks for an absurd amount of memory. This guide shows how to tell those three cases apart, and how its cousin, "GC overhead limit exceeded", fits in.
What the two messages mean
Oracle's troubleshooting guide for the JDK defines the detail message Java heap space plainly: an object could not be allocated in the Java heap. The collector has already run, could not free enough room, and the heap cannot grow any further because it has reached its maximum size. The guide adds that this "does not necessarily imply a memory leak": a heap that is too small for the workload produces exactly the same message.
Exception in thread "http-nio-8080-exec-7" java.lang.OutOfMemoryError: Java heap space
The second message is rarer and more interesting:
java.lang.OutOfMemoryError: GC overhead limit exceeded
It does not mean that an allocation failed. It means the JVM decided the application was no longer making progress. According to the same guide, the error is thrown when the application spends more than about 98% of its time in garbage collection while recovering less than 2% of the heap, and has been in that state for five consecutive collections. The typical situation is a live data set that barely fits in the heap, so each collection frees almost nothing and the next one starts immediately.
Both messages come out of a full heap. They are different from the other OutOfMemoryError variants that concern memory outside it, such as Metaspace, direct buffers or native threads, each of which has its own limit and its own fix.
Which collectors enforce the overhead limit
The policy is controlled by -XX:+UseGCOverheadLimit, enabled by default. For years, the java reference page described it in terms of the Parallel collector only, and in practice G1, the default collector on server-class machines, did not implement it: a G1 application in the same situation kept running, with almost all its CPU spent in back-to-back collections, until an allocation finally failed with "Java heap space" or until a health check killed it.
That changed with JDK-8212084, "G1: Implement UseGCOverheadLimit", integrated into the main OpenJDK repository in October 2025, which puts it in JDK 26. The bug tracker also lists backports to update releases of JDK 25, 21 and 17. In other words, the same service can report "GC overhead limit exceeded" on one JDK build and "Java heap space" or a timeout on another, for the same underlying problem. Check the exact release and collector before you compare incidents.
Turning the policy off with -XX:-UseGCOverheadLimit is almost never the right answer. It does not create memory; it trades an explicit error for a process that thrashes for longer before failing.
Three causes, three shapes of evidence
The fastest way to classify an incident is to look at the heap occupancy after each collection over time. The unified GC log gives it to you on every line, as explained in reading GC logs: in Pause Young (Normal) (G1 Evacuation Pause) 1530M->610M(2048M), the middle number is what survived.
1. The heap is simply too small
Occupancy after collection is roughly flat, with spikes that follow traffic. The failures arrive at peak load, during a batch job, or right after a deployment that loads a bigger cache or a new model. Nothing grows from one day to the next; the workload just does not fit. The cure here really is a bigger heap, or a smaller working set. Before you raise -Xmx, check where the current value comes from: without an explicit setting, the JVM uses MaxRAMPercentage, whose documented default is 25% of the available memory, which is often far less than people assume, especially inside a container. The arithmetic of heap versus container limit is covered in the JVM in containers.
2. Something is leaking
Occupancy after collection climbs steadily, from one hour or one day to the next, whatever the traffic. Old-generation or mixed collections get more frequent and recover less each time, and the process eventually dies with either message. Restarting fixes it for a while, and a bigger heap only moves the date of the next failure. The usual culprits are caches without a size bound, listeners and callbacks that are registered but never removed, ThreadLocal values in pooled threads, and maps keyed by objects whose equals and hashCode are inconsistent. Our guide to GC roots and memory leaks explains how such objects stay reachable.
3. One allocation is too large
Occupancy is normal right up to the failure, then a single request falls over. The stack trace points to Arrays.copyOf, a StringBuilder or a collection that is resizing, a readAllBytes() call, or a JSON or XML parser materialising a whole document. Someone uploaded a much larger file than usual, or a query returned millions of rows into a list. A bigger heap may hide it until the next, larger input; the real fix is streaming, pagination or a size check at the edge.
A close relative of this case has its own message, Requested array size exceeds VM limit: the program asked for an array longer than the virtual machine can create, whatever the heap size. It is always a code or input problem, never a tuning one.
Flat after-GC occupancy: undersized heap. Rising after-GC occupancy: leak. Normal occupancy and one failing request: oversized allocation. Decide which before touching -Xmx.
Capture the evidence before the restart
Classifying the incident is far easier with the right options already in place. Three are worth setting on every production JVM:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/dumps/
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m
The java reference documents that HeapDumpOnOutOfMemoryError is disabled by default and writes an HPROF file, named java_pid<pid>.hprof unless you choose another name, when the error is first thrown. Point it at a volume that survives the container and has room for a file about the size of the heap. How to open the dump and find the dominator that holds the memory is described in taking and reading a heap dump; for a leak, the biggest retained sizes usually name the culprit within minutes.
Decide also what the process should do once the error has been thrown. After an OutOfMemoryError, a JVM may keep running with threads that died halfway through their work, a half-updated cache and locks that were never released. HotSpot offers -XX:+ExitOnOutOfMemoryError to exit on the first occurrence, and -XX:+CrashOnOutOfMemoryError to exit with a fatal error log as well. -XX:OnOutOfMemoryError="<command>" runs a command of your choice. Where an orchestrator restarts failed instances, exiting cleanly is usually better than limping on, provided the heap dump is written first: the dump is taken before the exit.
A note on finalizers
The troubleshooting guide mentions one more cause that is easy to miss. Objects of a class with a finalize method are not reclaimed at the first collection that finds them unreachable: they are queued, and a single finalizer thread runs their finalizers later. If objects arrive faster than that thread can process them, the heap fills with garbage that cannot yet be freed. jmap -finalizerinfo <pid> and the getObjectPendingFinalizationCount method of MemoryMXBean show the size of the queue. Finalization has been deprecated for removal since JDK 18, so this is mostly a concern for older libraries; it is still worth a look when the dump is full of objects from a class that overrides finalize.
A short checklist
- Note the exact message, the JDK release and the collector (
java -XX:+PrintCommandLineFlags -versionshows the collector and the heap the JVM chose). - Plot heap occupancy after collection over days, not minutes.
- Flat: give the heap room or shrink the working set. Rising: take a heap dump and find what holds it. Spike: find the request and stream or bound it.
- Make sure the next incident leaves a dump and a GC log behind.
Sources
- Oracle, Java SE 21 Troubleshooting Guide, Troubleshoot Memory Leaks (detail messages, 98% / 2% criterion, finalizers)
- OpenJDK bug tracker, JDK-8212084: G1: Implement UseGCOverheadLimit (integration and backports)
- The
javacommand reference for JDK 21 (UseGCOverheadLimit, MaxRAMPercentage, HeapDumpOnOutOfMemoryError, HeapDumpPath, OnOutOfMemoryError) and JEP 421 (deprecation of finalization for removal)
MemoryTaking and reading a heap dump
Capture safely with jcmd, dump automatically on OOM, then read the dominator tree and paths to GC roots.
MemoryGC roots and memory leaks
What counts as a GC root, the usual accidental references, and how to walk from a suspect object back to its root.
ProductionThe JVM in containers
How limits are detected, what MaxRAMPercentage really sets, and a budget that keeps the OOM killer away.