# OutOfMemoryError: Java heap space in GitHub Actions

> Fix "OutOfMemoryError: Java heap space" in GitHub Actions by raising the heap of the JVM that actually failed, not the one your workflow configures.

Source: https://latchkey.dev/learn/failures/java-heap-space-out-of-memory-in-ci  
Updated: 2026-09-19

`OutOfMemoryError: Java heap space` in GitHub Actions means one JVM in your build exhausted its heap, and on Gradle and Maven builds it is usually not the JVM your workflow configures. Raise the heap of the process the stack trace names, or set `_JAVA_OPTIONS` so every JVM in the job gets it.

## What this error means

A compile, test or codegen step fails and the log carries `java.lang.OutOfMemoryError: Java heap space` with a stack trace at the allocation site. The build tool around it may report the failure in its own words first, as a failed task, a failed Surefire fork, or an expiring Gradle daemon, and the exception is often several screens above that summary. The same exception class appears with four other messages that are not the same problem: `GC overhead limit exceeded` means the heap is technically sufficient and the collector is thrashing, `Metaspace` means class metadata rather than objects, `Direct buffer memory` means off-heap NIO buffers, and `unable to create new native thread` means the process ran out of threads, not heap.

```Actions log, test step
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at ReportBuild.main(ReportBuild.java:9)
```

## Common causes

### The ceiling is below what the build needs

The JVM defaults its maximum heap to a quarter of the machine's memory, which is a sensible desktop default and a low one on a shared runner. A build whose live set grows with the repository will cross it eventually, and annotation processors, code generators and large fixtures cross it sooner than application code does.

### The heap you raised belongs to a different JVM

This is the most common reason a fix appears not to work. `org.gradle.jvmargs` sizes the daemon and has no effect on a forked test worker; `MAVEN_OPTS` sizes Maven and has no effect on a Surefire fork. The log names the process that died, and the setting has to match it.

### The message is not about the heap

The same exception class carries `Metaspace`, `Direct buffer memory` and `unable to create new native thread`, and none of them are fixed by `-Xmx`. Reading the message rather than the class name saves the two or three wasted runs that usually follow.

### Too many JVMs for one runner

Parallel Gradle workers, a matrix of forks, and `maven.test.fork.count` set from the core count all multiply the same ceiling. In our experience a build that fails only on CI and only sometimes is nearly always a parallelism setting reading a machine that is smaller than the developer's.

## How to fix it

### Raise the heap of the JVM that actually failed

1. Name the failing process from the thread name in the exception.
2. Set the ceiling where that process reads it: `org.gradle.jvmargs` for the daemon, `test { maxHeapSize }` for a test worker, `MAVEN_OPTS` for Maven, `<argLine>` for a Surefire fork.
3. Keep the sum of every concurrent JVM's ceiling under the runner's RAM.

```gradle.properties
# gradle.properties
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g

# build.gradle
test {
    maxHeapSize = "2g"
    maxParallelForks = 2
}
```

### Set it once for every JVM in the job

When the failing process is hard to reach, or the build shells out to tools you do not control, `_JAVA_OPTIONS` reaches all of them. The JVM applies it after the command line, so it also overrides an inline `-Xmx` that a wrapper script hardcoded. Every JVM it touches prints a `Picked up _JAVA_OPTIONS` line, so the change is visible in the log.

```.github/workflows/ci.yml
env:
  _JAVA_OPTIONS: -Xmx4g -Xms512m
```

### Size the right pool for Metaspace and direct memory

If the message is `Metaspace`, raise `-XX:MaxMetaspaceSize` and stop reusing a daemon between jobs. If it is `Direct buffer memory`, raise `-XX:MaxDirectMemorySize` or find the buffers that are not being released.

```.github/workflows/ci.yml
env:
  _JAVA_OPTIONS: -XX:MaxMetaspaceSize=1g
  GRADLE_OPTS: -Dorg.gradle.daemon=false
```

### Reduce the number of concurrent JVMs, or grow the runner

Pin `maxParallelForks` and `--max-workers` in CI rather than letting them read the core count, and if the build genuinely needs the memory, move it to a runner that has it. Two forks that finish beat four that die.

```.github/workflows/ci.yml
- run: ./gradlew test --max-workers=2 -Dorg.gradle.workers.max=2
```

## How to prevent it

- Commit the heap settings to `gradle.properties` or the Maven config so CI and laptops agree.
- Disable the Gradle daemon in CI: a fresh JVM per job cannot accumulate class metadata.
- Pin fork and worker counts instead of deriving them from the runner's core count.
- Keep the sum of concurrent JVM ceilings under the runner's RAM, so a heap error stays an exception rather than becoming a kernel kill.

## Find out which JVM died

A Gradle build is at least three JVMs: the launcher, the long-lived daemon that compiles, and a forked worker per test executor. Maven is two: the Maven process and whatever Surefire forks. Each one takes its heap from a different place, so the first job is to name the process in the stack trace rather than to pick a number.

The thread name in the exception is the tell. `Exception in thread "main"` is the process your command started. `Daemon worker` or the notice "Expiring Daemon because JVM heap space is exhausted" is the Gradle daemon. `Gradle Test Executor 1` or a Surefire `ForkedBooter` frame is a test fork, and a test fork does not read `org.gradle.jvmargs` at all.

## Which setting reaches which JVM

Set the value in the place the failing process reads, not in the first place a search result suggests. On our reproduction the JVM printed `Picked up _JAVA_OPTIONS: -Xmx4g -Xms512m` and completed a workload that had just failed under an inline `-Xmx64m`, which is the behavior the table below describes in the last row.

| Process that failed | Where its heap comes from | What does not reach it |
| --- | --- | --- |
| The JVM your step launches | `-Xmx` on the command line | Gradle and Maven settings |
| Gradle daemon | `org.gradle.jvmargs` in `gradle.properties`, or `GRADLE_OPTS` | `JAVA_OPTS` |
| Gradle test worker | `test { maxHeapSize = "2g" }` in the build script | `org.gradle.jvmargs` |
| Maven itself | `MAVEN_OPTS` | `JAVA_OPTS` |
| Surefire fork | `<argLine>` in the Surefire plugin config | `MAVEN_OPTS` |
| Every JVM in the job | `_JAVA_OPTIONS`, applied after the command line | nothing, which is the point |

## Metaspace, GC overhead and native threads are different failures

Raising `-Xmx` is the right move for `Java heap space` and for `GC overhead limit exceeded`, where the collector is spending most of its time recovering a little memory. It does nothing for the other three.

`Metaspace` is class metadata, sized by `-XX:MaxMetaspaceSize`, and it fills up on builds that load thousands of classes or reuse a long-lived daemon across jobs. `Direct buffer memory` is off-heap and sized by `-XX:MaxDirectMemorySize`. `unable to create new native thread` is not a memory ceiling at all; it is the process or thread limit, and the fix is less parallelism rather than more heap.

Latchkey's detection pattern groups all five messages under one signature, `JVM_HEAP_OOM`, at confidence 0.94, and its recorded caveat is the same distinction: the bump via `_JAVA_OPTIONS` is the standard fix for heap space and GC overhead, while native-thread exhaustion "is more nuanced (ulimit)".

## How much heap a runner can back

A standard Linux runner on a private repository has 8 GB of RAM for everything on the machine, and on a public repository 16 GB. A daemon at `-Xmx4g` plus two test forks at `-Xmx2g` is already past the smaller tier before the operating system takes its share. When the sum of the ceilings exceeds the machine, the JVM never gets to throw: the kernel kills the process first and the step ends at [exit code 137](/learn/failures/exit-code-137-in-github-actions) with no exception in the log.

## FAQ

### Why does raising org.gradle.jvmargs not fix my Gradle build?

Because it sizes the Gradle daemon, and the JVM that failed was probably a test worker. Gradle forks a separate JVM per test executor, and that fork takes its heap from `maxHeapSize` in the `test` block. Set both, or set `_JAVA_OPTIONS` so every JVM in the job gets the same ceiling.

### Is OutOfMemoryError: Metaspace the same problem as Java heap space?

No. Metaspace holds class metadata and is sized by `-XX:MaxMetaspaceSize`, a separate limit from `-Xmx`. Raising the heap will not move it. Metaspace exhaustion in CI usually means a reused daemon has loaded class definitions from many builds, so disabling the daemon fixes it as often as resizing does.

### How much heap can I give a JVM on a GitHub-hosted runner?

Less than the machine has. A standard Linux runner gives a private-repository job 2 vCPU and 8 GB of RAM, and a public-repository job 4 vCPU and 16 GB. Leave room for the operating system, the runner agent and any other JVM the build starts, which in practice means about 6 GB of total ceiling on the smaller tier.

### Does exit code 137 mean the JVM ran out of heap?

No. Exit 137 is SIGKILL, so the process was killed from outside and never got to throw anything. A heap exhaustion raises a Java exception and the build tool reports a failed task. If you see 137 with no exception, the ceilings you set add up to more than the runner has.

## References

- [Oracle: the java command and its -Xmx options](https://docs.oracle.com/en/java/javase/17/docs/specs/man/java.html)
- [Gradle: configuring the build environment](https://docs.gradle.org/current/userguide/build_environment.html)
- [GitHub-hosted runners: standard runner specifications](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)
- [gradle/gradle#8139: daemon heap exhaustion in CI](https://github.com/gradle/gradle/issues/8139)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
