Skip to content

Java OutOfMemoryError: Java Heap Space - Causes and Fixes

java.lang.OutOfMemoryError: Java heap space and GC overhead limit exceeded, explained: reproduce both, read the GC log, find the leak, and fix it properly.

Java java-errors outofmemoryerror garbage-collection java-memory-management java-21 core-java
Gopi Gorantala
Reading Progress

On This Page

1. The Error

The canonical form, from a compiled class on JDK 21 with -Xmx64m:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.base/java.util.HashMap.newNode(HashMap.java:1909)
	at java.base/java.util.HashMap.putVal(HashMap.java:637)
	at java.base/java.util.HashMap.put(HashMap.java:618)
	at Repro.main(Repro.java:9)

The sibling message, thrown by the Parallel collector only:

Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
	at Overhead.main(Overhead.java:8)

And the one that wastes the most debugging time, because it carries no stack trace at all:

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

That last line comes from HotSpot itself (JavaThread::exit), not from your code. The default uncaught-exception handler calls Throwable.printStackTrace(), which has to allocate a StackTraceElement[] and a pile of strings — on a heap that is still full, that allocation throws a second OutOfMemoryError, and the VM falls back to this one-liner. Same failure, no diagnostics.

Two more OutOfMemoryError detail messages are frequently confused with heap exhaustion but are not the same failure — different subsystem, different fix:

java.lang.OutOfMemoryError: Requested array size exceeds VM limit   # length > VM max array length, even on a huge heap
java.lang.OutOfMemoryError: Metaspace                              # class metadata, native memory, not -Xmx

Version note. The Java heap space wording has been stable since Java 1.4 and is identical on 8, 11, 17, 21 and 25. GC overhead limit exceeded is produced by the Parallel collector's adaptive size policy; on JDK 21.0.10 the same workload throws it under -XX:+UseParallelGC and plain Java heap space under Serial, G1 and ZGC. Since G1 became the default in JDK 9 (JEP 248), most teams stopped seeing this message the day they left Java 8 — the leak did not go away, only the label.

2. How to Reproduce It (step-by-step)

An unbounded cache — by far the most common shape in production:

// Repro.java  — run with: java -Xmx64m Repro.java   (Java 11+ single-file launcher)
import java.util.*;

public class Repro {
    static final Map<Integer, byte[]> CACHE = new HashMap<>();   // nothing ever evicts

    public static void main(String[] args) {
        for (int i = 0; ; i++) {
            CACHE.put(i, new byte[64 * 1024]);
            if (i % 100 == 0) System.out.println("entries=" + i);
        }
    }
}
javac Repro.java && java -Xmx64m -XX:+UseParallelGC Repro     # clean stack trace
java -Xmx64m Repro                                            # G1: handler dies, one-liner only

For GC overhead limit exceeded you need the thrash shape, not the cliff shape: live data that grows slowly toward the ceiling while the application keeps allocating short-lived garbage.

// Overhead.java — java -Xmx100m -XX:+UseParallelGC Overhead
import java.util.*;

public class Overhead {
    public static void main(String[] args) {
        Map<Integer, String> cache = new HashMap<>();
        for (int i = 0; ; i++) {
            cache.put(i, "session-" + i);                    // retained
            String scratch = new StringBuilder()             // garbage
                    .append("payload-").append(i).append('-').append(i * 31L).toString();
            if (scratch.isEmpty()) System.out.println("never");
        }
    }
}

A warning about writing these repros: if (scratch.isEmpty()) is not decoration. An earlier version of this program allocated a byte[4096] and never let it escape; C2's escape analysis scalar-replaced it and the loop ran 117 billion iterations in 90 seconds without a single GC. If your synthetic memory test never fails, check that the object actually escapes before you conclude the JVM is fine.

Flags that change what you see:

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app.hprof   # dump at the throw site
-XX:+ExitOnOutOfMemoryError                                           # exit(3), no stack trace
-XX:+CrashOnOutOfMemoryError                                          # hs_err_pid<N>.log + core
-XX:-UseGCOverheadLimit                                               # turn the Parallel heuristic off
-Xlog:gc,gc+heap=info:file=gc.log:time,uptime                         # the evidence you actually need

-XX:+HeapDumpOnOutOfMemoryError prints two extra lines before the throw, and is cheap enough to leave on permanently:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to /tmp/hd/app.hprof ...
Heap dump file created [64273423 bytes in 0.376 secs]

Environment-specific triggers. Only under load; only after hours of uptime (a leak, not a sizing bug); only in a container (-Xmx unset, see §3); only on the pod with the larger request volume; only after a JIT warmup that stopped scalar-replacing something.

3. Version Behaviour Matrix

JDKDefault GCGC overhead limit exceeded?What changed for heap OOM
8ParallelYesContainer limits ignored before 8u191; -Xmx relative to the host, not the cgroup
11G1 (JEP 248, JDK 9)No — plain Java heap spaceUseContainerSupport, MaxRAMPercentage available (JDK-8196595, JDK 10, backported to 8u191)
17G1NoCMS removed (JEP 363, JDK 14) → Unrecognized VM option 'UseConcMarkSweepGC' on startup; ZGC/Shenandoah production (JEP 377/379, JDK 15)
21G1NoGenerational ZGC (JEP 439) opt-in via -XX:+ZGenerational; virtual threads make thread-local retention a new leak surface
25G1NoNon-generational ZGC removed (JEP 490, JDK 24) so -XX:+ZGenerational is gone; compact object headers (JEP 519) available as -XX:+UseCompactObjectHeaders (12 → 8 byte headers, off by default)

Verified on 21.0.10, -XX:+PrintFlagsFinal: UseGCOverheadLimit=true, GCTimeLimit=98, GCHeapFreeLimit=2, MaxRAMPercentage=25.0, UseContainerSupport=true, HeapDumpOnOutOfMemoryError=false.

One ergonomics trap worth knowing: with a single available processor the JVM still selects Serial, whatever the heap size.

$ java -XX:ActiveProcessorCount=1 -XX:MaxRAM=4g -XX:+PrintFlagsFinal -version | grep UseSerialGC
     bool UseSerialGC = true   {product} {ergonomic}

So a 1-CPU sidecar and a 4-CPU pod running the same image can fail differently. JEP 523 proposes making G1 the default in all environments, but it is a Candidate, not shipped.

4. Why It Happens — Surface Level

OutOfMemoryError: Java heap space means one specific thing: an allocation request could not be satisfied after a full, compacting collection. It is not "the heap is full" — the heap is allowed to be full. It is "everything still in it is reachable, and there is no contiguous space for the next object."

So there are exactly three causes, and you must decide which one you have before touching a flag:

  1. A leak — objects stay reachable forever (a static Map, a ThreadLocal on a pooled thread, a listener list nobody unregisters). Live set grows with uptime.
  2. A sizing mismatch — the live set is legitimate and simply larger than -Xmx. Live set is flat but too high.
  3. An allocation spike — one request materialises a 2 GB result set or an unbounded LinkedBlockingQueue drains slower than it fills. Live set is flat until it isn't.

Raising -Xmx fixes (2), delays (3), and buys a few hours before the same page for (1).

5. Why It Happens — Under the Hood

The allocation slow path. Threads bump-allocate in their TLABs. When a TLAB is exhausted the JVM tries a new one from the eden regions; when that fails it enters MemAllocator::allocateCollectedHeap::mem_allocate → the collector's attempt_allocation_slow, which triggers a young collection and retries. If enough retries fail, the collector escalates to a full compacting collection and retries again. Only when that fails does report_java_out_of_memory("Java heap space") fire. The stack trace you see is the frame that lost the race, not the frame that caused the problem — HashMap.newNode in the trace above is innocent.

The G1 death spiral. Watch what full GCs look like when the live set is the problem:

[0.156s][info][gc] GC(21) Pause Full (G1 Compaction Pause) 59M->59M(64M) 2.798ms
[0.159s][info][gc] GC(22) Pause Full (G1 Compaction Pause) 59M->59M(64M) 2.785ms
[0.163s][info][gc] GC(24) Pause Full (G1 Compaction Pause) 59M->59M(64M) 2.863ms

59M->59M is the whole diagnosis: a full compaction recovered nothing. Heap occupancy after a full GC is the single metric that separates a leak from a sizing problem; a sawtooth that returns to a flat baseline is healthy, a staircase is a leak.

The Parallel heuristic. Oracle's definition is precise: after a collection, if the application spends more than ~98% of its time in GC and recovers less than 2% of the heap, for five consecutive collections, GC overhead limit exceeded is thrown. Those are GCTimeLimit=98, GCHeapFreeLimit=2 and a compile-time constant of 5, evaluated by the Parallel collector's adaptive size policy. G1, Serial and ZGC do not implement it, which is why the message vanished when you upgraded off Java 8 — the JVM simply keeps thrashing until an allocation genuinely fails.

Fragmentation and G1 humongous objects. G1 splits the heap into regions (-XX:G1HeapRegionSize, ergonomically 1 MB here). Any allocation larger than half a region is humongous: it is allocated in a contiguous run of old-gen regions, and until JDK 8u40+ improvements it could only be reclaimed by a concurrent cycle or a full GC. Large byte[] buffers therefore fail at a heap occupancy well below 100%:

[0.038s][info][gc,heap] GC(0) Humongous regions: 27->27
[0.038s][info][gc     ] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation) 28M->28M(64M)
[0.054s][info][gc     ] GC(2) Pause Young (Prepare Mixed) (G1 Humongous Allocation) (Evacuation Failure) 61M->61M(64M)

An Evacuation Failure line means G1 could not find space to copy survivors into — the precursor to the full-GC spiral.

OutOfMemoryError is an Error, not an Exception. catch (Exception e) will not catch it, and it does not kill the JVM: it kills the thread. In a pool, the thread dies, the pool replaces it, and the error is boxed into the Future:

main is still alive; future.isDone()=true
caught: java.util.concurrent.ExecutionException: java.lang.OutOfMemoryError: Java heap space

If nobody calls Future.get(), the OOM is silently swallowed and your service limps on with a poisoned heap. This is the same wrapping behaviour that makes RejectedExecutionException and CompletionException hard to trace, and it is the reason -XX:+ExitOnOutOfMemoryError is usually the right production setting: a heap-exhausted JVM is not a JVM you want serving traffic.

Containers. UseContainerSupport reads the cgroup limit, but MaxRAMPercentage defaults to 25%. A pod with a 2 GB limit and no -Xmx gets a 512 MB heap and OOMs long before the container does — then the same container gets OOMKilled (exit 137) if you overshoot in the other direction, because heap is only part of the JVM's footprint.

$ java -XX:MaxRAM=512m -XX:+PrintFlagsFinal -version | grep MaxHeapSize
   size_t MaxHeapSize = 134217728   {product} {ergonomic}          # 128 MB = 25%
$ java -XX:MaxRAM=512m -XX:MaxRAMPercentage=75 -XX:+PrintFlagsFinal -version | grep MaxHeapSize
   size_t MaxHeapSize = 402653184   {product} {ergonomic}          # 384 MB

6. The Fix

Fix 1 — bound the cache (the actual fix for the repro).

-static final Map<Integer, byte[]> CACHE = new HashMap<>();
+// Bounded LRU: eviction is a property of the data structure, not of a cron job.
+static final Map<Integer, byte[]> CACHE = Collections.synchronizedMap(
+        new LinkedHashMap<>(16, 0.75f, true) {
+            @Override protected boolean removeEldestEntry(Map.Entry<Integer, byte[]> e) {
+                return size() > 10_000;
+            }
+        });

For anything real, prefer Caffeine with maximumWeight + expireAfterWrite over a hand-rolled LinkedHashMap.

Fix 2 — stop materialising whole result sets. Replace List<Row> all = repo.findAll() with a cursor/Stream and a fixed JDBC fetch size, or paginate. This is the fix for the "only under load" variant.

Fix 3 — size the heap explicitly, especially in containers.

-ENTRYPOINT ["java", "-jar", "/app.jar"]
+ENTRYPOINT ["java", \
+  "-XX:MaxRAMPercentage=70", \
+  "-XX:+ExitOnOutOfMemoryError", \
+  "-XX:+HeapDumpOnOutOfMemoryError", "-XX:HeapDumpPath=/dumps", \
+  "-Xlog:gc*:file=/dumps/gc.log:time,uptime:filecount=5,filesize=20M", \
+  "-jar", "/app.jar"]

Use this only after you have confirmed from GC logs that the live set is legitimate. Sizing is a fix for cause (2), a workaround for (3), and a delay tactic for (1).

Fix 4 — do not use -XX:-UseGCOverheadLimit. It suppresses the symptom on Parallel and converts a fast failure into an unbounded stall. The only defensible use is a batch job you would rather have finish slowly than restart.

7. Best Practices & The Better Design

The structural fix is to make unbounded retention impossible rather than unlikely:

  • Every cache has a bound. Size, weight, or TTL — chosen deliberately. An unbounded Map used as a cache is a leak with a grace period.
  • Every queue has a capacity. new LinkedBlockingQueue<>() is unbounded; Executors.newFixedThreadPool(n) uses exactly that, so a slow consumer converts backpressure into heap growth. Use new ThreadPoolExecutor(..., new ArrayBlockingQueue<>(1000), new CallerRunsPolicy()) and take the RejectedExecutionException — a rejected task is a signal; an OOM is an outage.
  • Every ThreadLocal on a pooled thread is removed in a finally. With virtual threads (one per request, millions of them) the mistake inverts: a ThreadLocal that was cheap on 200 platform threads is a heap disaster on 2,000,000 virtual ones. Prefer scoped values.
  • Stream, don't collect. Files.lines(...) in try-with-resources beats Files.readAllLines(...); a JDBC cursor beats findAll().
  • Weak/soft references are not a cache policy. SoftReference defers the decision to the collector, which will keep them until the heap is nearly exhausted — you get the OOM later, plus GC pauses.

Rewritten so the failure cannot happen:

import java.util.*;
import java.util.concurrent.*;

public class Bounded {
    private static final int MAX_ENTRIES = 10_000;

    private static final Map<Integer, byte[]> CACHE = Collections.synchronizedMap(
            new LinkedHashMap<>(16, 0.75f, true) {
                @Override protected boolean removeEldestEntry(Map.Entry<Integer, byte[]> eldest) {
                    return size() > MAX_ENTRIES;
                }
            });

    public static void main(String[] args) throws Exception {
        ExecutorService pool = new ThreadPoolExecutor(
                4, 4, 0L, TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<>(1_000),
                new ThreadPoolExecutor.CallerRunsPolicy());
        for (int i = 0; i < 1_000_000; i++) {
            final int id = i;
            pool.execute(() -> CACHE.put(id, new byte[64 * 1024]));
        }
        pool.shutdown();
        pool.awaitTermination(1, TimeUnit.MINUTES);
        System.out.println("entries retained = " + CACHE.size());   // 10000
    }
}

8. How to Prevent It Long-Term

Always on in production: -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=<persistent volume> (in Kubernetes the path must survive the restart), -XX:+ExitOnOutOfMemoryError so the orchestrator restarts a healthy process, and rotating GC logs via -Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=20M.

Alert on the right metric. Not "heap used" — that sawtooths by design. Alert on heap occupancy after the last full/concurrent collection (jvm_memory_pool_bytes_used{pool="G1 Old Gen"} sampled post-GC in Micrometer), plus GC time as a fraction of wall clock. A steadily rising post-GC baseline is a leak, weeks before the page.

Live triage without a restart:

jcmd <pid> GC.heap_info        # garbage-first heap total 524288K, used 523595K ... region size 1024K
jcmd <pid> GC.class_histogram  # what is on the heap, by count and bytes
jcmd <pid> GC.heap_dump /dumps/live.hprof

Open the .hprof in Eclipse MAT and go straight to Leak Suspectsdominator treepath to GC roots (exclude weak/soft). The dominator tree is what answers "who is keeping this alive"; a class histogram alone never will.

Catch it before production: JFR with -XX:StartFlightRecording:settings=profile gives you jdk.OldObjectSample (objects that survived and where they were allocated — the single best leak event), plus jdk.GCHeapSummary, jdk.ObjectAllocationSample and jdk.AllocationRequiringGC. Run a soak test at production-like -Xmx for hours, not a 5-minute smoke test. Add a CI job on the next LTS with the same heap settings, since a default-GC change alters both the failure mode and the message. SpotBugs and Error Prone will not find a leak, but a code-review rule — no unbounded collection field, no unbounded queue, no ThreadLocal without remove() — will.

Related failures worth distinguishing: OutOfMemoryError: Metaspace (class metadata, -XX:MaxMetaspaceSize, classloader leaks), unable to create new native thread (OS limits, not -Xmx), Direct buffer memory (-XX:MaxDirectMemorySize, NIO/Netty), and container OOMKilled / exit 137 (total RSS, of which heap is only a part).

9. Key Takeaways

  • Java heap space means an allocation failed after a full GC — three causes only: leak, undersized heap, or allocation spike. Diagnose before you resize.
  • GC overhead limit exceeded is a Parallel-collector-only message (98% GC time, <2% recovered, 5 collections in a row). It disappeared when G1 became the default in JDK 9; the underlying thrash did not.
  • Heap occupancy after a full/concurrent collection is the metric that separates a leak from a sizing problem. 59M->59M(64M) in the GC log is a complete diagnosis.
  • OutOfMemoryError kills a thread, not the JVM, and Future.get() rewraps it in ExecutionException — set -XX:+ExitOnOutOfMemoryError so a poisoned JVM stops serving traffic.
  • In containers, MaxRAMPercentage defaults to 25%. Set it explicitly, and ship -XX:+HeapDumpOnOutOfMemoryError with a dump path that survives the restart.
Javajava-errorsoutofmemoryerrorgarbage-collectionjava-memory-managementjava-21core-java

I'm Gopi — 15+ years in Java, building Kafka and Flink platforms for banks, where one lost event is a financial discrepancy. I write javahandbook.com because the guides I needed didn't exist. Everything here is tested against a real cluster first.

Comments