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 -XmxVersion 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 onlyFor 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
| JDK | Default GC | GC overhead limit exceeded? | What changed for heap OOM |
|---|---|---|---|
| 8 | Parallel | Yes | Container limits ignored before 8u191; -Xmx relative to the host, not the cgroup |
| 11 | G1 (JEP 248, JDK 9) | No — plain Java heap space | UseContainerSupport, MaxRAMPercentage available (JDK-8196595, JDK 10, backported to 8u191) |
| 17 | G1 | No | CMS removed (JEP 363, JDK 14) → Unrecognized VM option 'UseConcMarkSweepGC' on startup; ZGC/Shenandoah production (JEP 377/379, JDK 15) |
| 21 | G1 | No | Generational ZGC (JEP 439) opt-in via -XX:+ZGenerational; virtual threads make thread-local retention a new leak surface |
| 25 | G1 | No | Non-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:
- A leak — objects stay reachable forever (a static
Map, aThreadLocalon a pooled thread, a listener list nobody unregisters). Live set grows with uptime. - A sizing mismatch — the live set is legitimate and simply larger than
-Xmx. Live set is flat but too high. - An allocation spike — one request materialises a 2 GB result set or an unbounded
LinkedBlockingQueuedrains 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::allocate → CollectedHeap::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.863ms59M->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 spaceIf 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 MB6. 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
Mapused 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. Usenew ThreadPoolExecutor(..., new ArrayBlockingQueue<>(1000), new CallerRunsPolicy())and take theRejectedExecutionException— a rejected task is a signal; an OOM is an outage. - Every
ThreadLocalon a pooled thread is removed in afinally. With virtual threads (one per request, millions of them) the mistake inverts: aThreadLocalthat 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 beatsFiles.readAllLines(...); a JDBC cursor beatsfindAll(). - Weak/soft references are not a cache policy.
SoftReferencedefers 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.hprofOpen the .hprof in Eclipse MAT and go straight to Leak Suspects → dominator tree → path 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 spacemeans an allocation failed after a full GC — three causes only: leak, undersized heap, or allocation spike. Diagnose before you resize.GC overhead limit exceededis 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. OutOfMemoryErrorkills a thread, not the JVM, andFuture.get()rewraps it inExecutionException— set-XX:+ExitOnOutOfMemoryErrorso a poisoned JVM stops serving traffic.- In containers,
MaxRAMPercentagedefaults to 25%. Set it explicitly, and ship-XX:+HeapDumpOnOutOfMemoryErrorwith a dump path that survives the restart.