java.lang.OutOfMemoryError: Java heap space

Quick answer

The heap filled up and GC couldn't free enough to satisfy an allocation. Don't raise -Xmx yet — first find out which problem you have:

  • Post-GC memory returns to a flat baseline → heap is just too small; raise -Xmx.
  • Post-GC baseline creeps upward forever → memory leak; a bigger heap only delays the crash.
  • One giant allocation (a huge file/query) → stream it instead of loading it whole.
  • In a container? Use -XX:MaxRAMPercentage, not a hardcoded -Xmx.

The exact error string

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
        at java.base/java.util.Arrays.copyOf(Arrays.java:3512)
        at java.base/java.lang.AbstractStringBuilder.ensureCapacity(AbstractStringBuilder.java:239)
        at com.example.ReportBuilder.build(ReportBuilder.java:88)
        at com.example.Main.main(Main.java:24)

An important thing to understand up front: the stack trace shows where memory ran out, not what caused it. The frame at the top is simply the unlucky allocation that happened to be the last straw — often something innocuous like growing a StringBuilder or copying an array. The objects actually filling the heap were usually allocated somewhere else entirely, possibly minutes earlier. Chasing the line in the stack trace is the single most common wasted afternoon with this error.

Step 1: leak, or just too small?

Everything downstream depends on this one question, and it has a precise answer. Watch what happens to heap usage after each full garbage collection — the low points, not the peaks:

Healthy — heap just too small flat post-GC baseline stays level → raise -Xmx (Fix 1) Leak — retention grows -Xmx ceiling post-GC baseline climbs → OOM → find the leak (Fix 2) Compare the LOW points, not the peaks -Xlog:gc* — or a profiler — shows the baseline flat = size problem · rising = retention problem

Peaks always look alarming in both cases. Only the post-GC baseline distinguishes a heap that's too small from one that's leaking.

# log every GC with heap before/after — the cheapest way to see the baseline
java -Xlog:gc* -jar app.jar

# JDK 8 equivalent
java -XX:+PrintGCDetails -XX:+PrintGCDateStamps -jar app.jar

# live view of a running process
jcmd <pid> GC.heap_info
jstat -gcutil <pid> 5s

Fix 1: the heap is genuinely too small

If the baseline is flat and the application simply needs more room at peak load, raising the maximum heap is the right and complete fix:

java -Xms1g -Xmx2g -jar app.jar

Setting -Xms (initial) equal to -Xmx (maximum) is common for servers: it avoids the cost of growing the heap incrementally and makes memory behaviour predictable from the first request. The trade-off is that the process reserves that much up front.

Be aware that a bigger heap is not free. Full-GC pauses scale with heap size, so a very large heap can turn a memory problem into a latency problem. If you're pushing past a few gigabytes, it's usually worth switching collectors (G1 is the default on modern JDKs; ZGC or Shenandoah for low-pause requirements) rather than just growing the number.

Fix 2: it's a leak — find what's retaining memory

If the baseline climbs, no -Xmx value will save you; it just moves the crash later. Capture a heap dump and find what's holding the objects alive:

# write a dump automatically when it fails — set this in production ahead of time
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp -jar app.jar

# or take one on demand from a running process (live = after a full GC)
jmap -dump:live,format=b,file=heap.hprof <pid>

Open the .hprof in Eclipse MAT (the standard tool) or VisualVM and run the Leak Suspects report. The critical view is the dominator tree: it doesn't just show which objects are biggest, it shows the reference chain keeping them alive — which is the actual answer to "why isn't this being collected?"

The recurring culprits, roughly in order of how often they turn up:

PatternWhy it retainsFix
Unbounded cache (a static Map that only grows)Nothing ever evicts entriesUse Caffeine/Guava with a size or TTL bound
Listeners / callbacks registered, never removedThe registry holds a strong reference foreverDeregister in a close()/lifecycle hook
ThreadLocal not cleared on pooled threadsThe thread outlives the request, so the value does tooremove() in a finally block
Long-lived static collectionsClass-level roots are never collectedScope to a bean/instance, or bound the size
Unclosed streams/connectionsNative and buffer memory pinnedtry-with-resources

Fix 3: one huge allocation — stream instead of loading

Sometimes there's no leak and the heap isn't unreasonably small: a single operation just tries to materialise something enormous. Reading a large file, or a query with no LIMIT, into a list is the usual shape:

// ❌ entire file in memory at once
List<String> lines = Files.readAllLines(Path.of("huge.log"));

// ✅ stream it — one line in memory at a time
try (Stream<String> lines = Files.lines(Path.of("huge.log"))) {
    lines.filter(l -> l.contains("ERROR")).forEach(this::handle);
}

// ❌ unbounded result set — the whole table lands in a List in memory
List<Row> all = jdbc.query("SELECT * FROM events", rowMapper);

// ✅ page with LIMIT/OFFSET (or a keyset), or stream with a real server-side cursor
jdbc.setFetchSize(1000);

setFetchSize is a hint, not a guarantee — most drivers happily ignore it and buffer the full result set anyway unless additional conditions are met. On PostgreSQL it only takes effect with auto-commit off (connection.setAutoCommit(false)); on MySQL the driver requires useCursorFetch=true in the connection URL, or a MySQL-specific streaming mode via Statement.setFetchSize(Integer.MIN_VALUE), which has its own restrictions (e.g. you must read the whole result set before issuing another query on that connection). Check your driver's own documentation before assuming setFetchSize alone keeps a large query off the heap — the safer bet for genuinely huge result sets is explicit paging (LIMIT/OFFSET or a keyset) at the query level.

The same trap applies to parsing a large JSON document with a tree-based parser: the whole object graph lands on the heap at once. Streaming parsers (Jackson's JsonParser, or Gson's JsonReader) process it incrementally instead. If you're working out the shape of a large payload before deciding how to parse it, our JSON Formatter is a quick way to inspect its structure without writing throwaway code — and the equivalent decode problems in Go follow the same "decode into a typed target, don't hold it all" principle.

Fix 4: sizing the heap inside a container

This deserves its own treatment, because the failure mode is different and confusing. In Docker or Kubernetes there are two ceilings: the JVM's -Xmx, and the container's memory limit enforced by the kernel. Which one you hit determines what you see:

What happensWhich ceilingSymptom
JVM heap exhausted-XmxOutOfMemoryError: Java heap space (this page) — the JVM throws, stack trace available
Process exceeds container limitcgroup limitKilled with exit code 137 (OOMKilled) — no Java exception at all

The second case surprises people because there's no Java error to read — the process simply vanishes. It happens when -Xmx is set close to the container limit, leaving no headroom for everything that lives outside the heap: thread stacks, metaspace, the JIT code cache, GC structures, and native/direct buffers. Those can easily add several hundred megabytes.

# ❌ container limit 512m, heap allowed 512m — no room for non-heap memory
docker run -m 512m myapp java -Xmx512m -jar app.jar

# ✅ let the JVM size itself from the container limit, with headroom
docker run -m 512m myapp java -XX:MaxRAMPercentage=75.0 -jar app.jar

Modern JDKs (10+) are container-aware by default — UseContainerSupport is on, so the JVM reads the cgroup limit rather than the host's total RAM. -XX:MaxRAMPercentage is therefore the better flag in containers: it scales automatically when you change the limit, instead of silently becoming wrong the next time someone edits the deployment.

The container-aware default is 25%, which is lower than most people expect

"Container-aware" is easy to read as "sized sensibly to the limit." The number is worth knowing exactly, because it is conservative: with no heap flags at all, the default MaxRAMPercentage is 25.0, so three quarters of the container's memory is left unused by the heap. Measured on eclipse-temurin:21-jre-alpine:

$ docker run --rm --memory=<LIMIT> eclipse-temurin:21-jre-alpine \
    java -XX:+PrintFlagsFinal -version | grep -E 'MaxHeapSize|MaxRAMPercentage'

--memory=512m  →  MaxHeapSize = 128 MB   MaxRAMPercentage = 25.0
--memory=1g    →  MaxHeapSize = 256 MB   MaxRAMPercentage = 25.0
--memory=2g    →  MaxHeapSize = 512 MB   MaxRAMPercentage = 25.0

bool UseContainerSupport = true {product} {default}

That cuts both ways. It is why a service can throw OutOfMemoryError: Java heap space in a 2 GB container while docker stats shows it using well under a gigabyte — the heap ceiling was 512 MB and the other 1.5 GB was never available to it. If that is your situation, the heap is not "genuinely too small" in the sense of Fix 1; it is being capped at a quarter of what you provisioned, and raising MaxRAMPercentage is the fix rather than enlarging the container.

One caution in the other direction, which is the real reason this flag is not just "set it to 90". An explicit -Xmx overrides container awareness — UseContainerSupport supplies the default, it does not cap a value you asked for. A JVM given -Xmx512m inside a 128 MB container reports maxMemory=494MB, happily allocates past the cgroup limit, and is killed by the kernel mid-allocation:

# heap under the limit  → the JVM throws, and you get a stack trace to read
--memory=256m, -Xmx64m  :  maxMemory=61MB   →  java.lang.OutOfMemoryError: Java heap space
                                               ExitCode=0   OOMKilled=false

# heap over the limit    → killed before it can diagnose itself, no Java output at all
--memory=128m, -Xmx512m :  maxMemory=494MB  →  (nothing)
                                               ExitCode=137 OOMKilled=true

This gives you a clean way to tell the two ceilings apart after the fact, which is the practical version of the table above: if you have a Java stack trace, the JVM stayed inside its own limit and you are on the right page. If the container died with no Java output at all, -Xmx was larger than the container limit — the absence of a stack trace is the diagnosis, not missing logs, and exit code 137 / OOMKilled covers it.

You cannot fill -Xmx with live data

One more measured number, because it affects how you size from a known working set. Retaining 1 MB blocks in an ArrayList under -Xmx32m died after 14 MB — under half the heap:

$ java -Xmx32m Oom.java
max heap = 32 MB
caught: java.lang.OutOfMemoryError: Java heap space
retained 14 MB before dying

The rest went to GC headroom (a collector needs somewhere to copy survivors to) and to the growing list's own backing array, which must briefly hold both the old and new copy while it doubles. So if you know your live set is 600 MB, -Xmx600m will not run — budget meaningfully above the working set rather than at it.

Measured on Java 21.0.11 (OpenJDK 64-Bit Server VM, build 21.0.11+9-LTS-211), container figures on eclipse-temurin:21-jre-alpine under Docker 25.0.2. The 25 % default and UseContainerSupport have been stable since JDK 10, but both are tunable, so read them off your own runtime with -XX:+PrintFlagsFinal rather than assuming.

Related OutOfMemoryError variants

The message after OutOfMemoryError: tells you which region ran out, and they have different fixes:

MessageMeaning
Java heap spaceThe object heap is full — this page
GC overhead limit exceeded>98% of time in GC recovering <2% — effectively the same condition, caught earlier. Mostly seen with Parallel GC; G1, the default collector since JDK 9, typically throws plain Java heap space instead
MetaspaceClass metadata, not objects — usually classloader leaks (frequent redeploys)
Requested array size exceeds VM limitA single array larger than the JVM allows — almost always a bug in a size calculation
unable to create new native threadOS thread limit, not heap — usually an unbounded thread pool

Debugging checklist

Frequently Asked Questions

What does java.lang.OutOfMemoryError: Java heap space mean?

The JVM tried to allocate an object, the heap had no room, and the garbage collector could not free enough space to satisfy the request. It is not a crash in your code so much as a resource exhaustion: either the application genuinely needs more heap than it was given, or it is holding on to objects it should have released — a memory leak.

Should I just increase -Xmx?

Only after you know which problem you have. If memory usage rises to a stable plateau and the workload genuinely needs more room, raising -Xmx is the correct fix. If usage climbs steadily until it hits whatever ceiling you set, that is a leak — a bigger heap only postpones the same crash and makes the eventual garbage-collection pauses worse. Check the trend before changing the flag.

How do I tell a memory leak from an undersized heap?

Watch heap usage after full garbage collections over time. If the post-GC baseline stays flat, the heap is merely too small for peak load. If the post-GC baseline creeps steadily upward — never returning to its earlier level — objects are being retained and you have a leak. Enable GC logging with -Xlog:gc*, or watch live in a profiler, and compare the low points rather than the peaks.

How do I capture and read a heap dump?

Add -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp so the JVM writes a dump automatically when it fails, or take one on demand with jmap -dump:live,format=b,file=heap.hprof <pid>. Open the .hprof file in Eclipse MAT or VisualVM and run a leak-suspects report — it ranks which objects dominate the heap and, more usefully, shows the reference chain keeping them alive.

Why does my Java container get killed instead of throwing OutOfMemoryError?

Those are two different failures. OutOfMemoryError means the JVM hit its own heap ceiling and threw. Being killed with exit code 137 means the kernel's OOM killer terminated the whole process for exceeding the container's memory limit — no Java exception is thrown at all. That usually means -Xmx was set too close to the container limit, leaving no headroom for thread stacks, metaspace and native buffers, which live outside the heap.

What are the most common causes of a Java heap leak?

An unbounded collection used as a cache with no eviction is the classic — a static Map that only ever grows. Others include listeners or callbacks registered but never removed, ThreadLocal values not cleared on pooled threads, unclosed resources holding buffers, and objects held alive by long-lived static references. A heap dump's dominator tree points at whichever applies, because it shows what is actually retaining the memory.

How is this different from 'GC overhead limit exceeded'?

They are two symptoms of the same underlying condition. ‘Java heap space’ means an allocation outright failed. ‘GC overhead limit exceeded’ means the JVM is spending over 98% of its time collecting and recovering less than 2% of the heap — it gave up before allocation failed outright. Both indicate the heap is effectively exhausted, and both are diagnosed the same way. In practice you're more likely to see it on Parallel GC (where the check originates); G1, the default collector since JDK 9, typically throws plain ‘Java heap space’ instead of this variant.

References

More Java & memory errors

Browse the full reference for Java, Go, and Rust errors — exact message, cause, and fix.

All Error References Docker: exit code 137 (OOMKilled) Node: JS heap out of memory
About the author

Pasindu Ishan is a Senior Software Engineer working on data streaming systems, with Java as the day-to-day language. These pages follow the order he reads them in himself: the JVM’s own diagnostics first, then the fix that follows from them. He builds the free in-browser tools at JSON Dev Tools.