Sizing Runners for Java and Gradle Builds
Size a Gradle runner from the memory budget first: daemon heap plus forked test heaps must fit under RAM. Sizing table, warp- labels, rates, and cost math.
Last verified:
Most Java and Gradle builds belong on 8 vCPU with 32GB of memory, which is warp-ubuntu-latest-x64-8x at $0.016 per minute. Memory sets the ceiling before cores do, because a Gradle build commits the daemon heap plus one heap per forked test worker, and that sum has to fit under the machine's RAM.
This guide gives the table that maps org.gradle.workers.max and JVM heap onto the Linux runner ladder, the memory arithmetic that turns a reasonable looking --max-workers into an exit code 137, and the workflow YAML that pins both to the label you selected.
Diagnosis
A Gradle build spends its wall clock in four phases with different resource shapes.
- Dependency resolution and wrapper download: network bound, one core busy.
- Configuration: largely single threaded until the configuration cache is warm.
- Compilation: parallel across modules when
org.gradle.parallel=true, bounded byorg.gradle.workers.max. - Tests: parallel across forked JVMs, bounded by
maxParallelForkson each test task.
Only the last two convert extra cores into shorter runs. A single-module service with a 90 second test suite gains almost nothing from 32 vCPU, while a 40-module monorepo with sharded tests keeps every worker busy.
The memory arithmetic is where sizing goes wrong. Each Gradle worker and each forked test JVM is a separate process with its own heap, and the JVM sets its default maximum heap to one quarter of the machine's physical memory when nothing overrides it. On a 32GB runner that default is 8GB per process. A test task with maxParallelForks = 4 and no explicit -Xmx therefore permits 32GB of test heap on a 32GB machine, before counting the Gradle daemon's own heap, Metaspace, thread stacks, and the page cache that keeps build cache reads off the disk.
Three symptoms follow from that overcommit, and they look unrelated until you add up the heaps:
- The job ends with exit code 137, which is the kernel OOM killer. The log above it usually reads "Gradle build daemon disappeared unexpectedly" or reports that a worker process crashed.
- Test failures move between classes on consecutive runs of the same commit, because which fork the kernel kills varies.
- Wall clock rises while CPU utilization stays low, which is the signature of GC pressure rather than a slow machine.
Measure before resizing. CI observability reports maximum CPU utilization, maximum memory utilization, filesystem utilization, and disk throughput per runner instance, grouped from repository down to job and instance type, and it flags any instance holding at or above 80 percent memory utilization as High Memory Usage. The observability documentation covers the Recommendations view that carries those thresholds. A Gradle job sitting at 95 percent memory and 30 percent CPU needs a heap change before it needs a bigger machine.
Fix
Size from a budget, then check the cores against it.
Committed heap equals the daemon -Xmx plus maxParallelForks multiplied by the fork -Xmx. Keep that total at or under two thirds of the runner's RAM. The remaining third absorbs Metaspace, JIT code cache, thread stacks, annotation processors, Kotlin compile daemons, and the page cache.
This table applies the rule across the Linux x64 ladder in the cloud runners documentation, with per-minute rates from the pricing page:
| Runner label | vCPU | RAM | Rate per minute | org.gradle.workers.max | Daemon -Xmx | Test forks x fork -Xmx | Committed heap |
|---|---|---|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | 2 | 8GB | $0.004 | 2 | 2g | 1 x 2g | 4GB of 8GB |
| warp-ubuntu-latest-x64-4x | 4 | 16GB | $0.008 | 4 | 4g | 2 x 3g | 10GB of 16GB |
| warp-ubuntu-latest-x64-8x | 8 | 32GB | $0.016 | 8 | 6g | 4 x 4g | 22GB of 32GB |
| warp-ubuntu-latest-x64-16x | 16 | 64GB | $0.032 | 12 | 8g | 8 x 4g | 40GB of 64GB |
| warp-ubuntu-latest-x64-32x | 32 | 128GB | $0.064 | 16 | 12g | 12 x 6g | 84GB of 128GB |
Two rows deserve an explanation. Worker count tracks vCPU up to the 8x size, and then falls behind it: at 16 and 32 vCPU the heap budget binds first, and forked test JVMs are already consuming cores that the compile workers would otherwise use. The 2x row is a single-module build with one test fork; a multi-module Gradle build on 8GB spends its time in GC.
Both levers are available on the same catalog. All Linux sizes carry 150GB of SSD storage with 4GB of memory per vCPU. Pure JVM builds also run on the ARM64 ladder, where the same 8 vCPU and 32GB shape is warp-ubuntu-latest-arm64-8x at $0.012 per minute; check any JNI dependencies and Testcontainers images for ARM64 builds before switching architecture.
The rate lever works without any assumption about scaling. warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price, checked on 2026-08-13 against GitHub's per-minute rate reference. At the top of the ladder, warp-ubuntu-latest-x64-32x (32 vCPU, 128 GB) costs $0.064 per minute against $0.082 per minute for the 32-core Linux larger runner (32 vCPU, 128 GB): 22 percent lower list price, same source and date.
Two product features change the sizing conversation for Java work. Snapshot runners bring back a populated Gradle User Home so dependency resolution stops competing for the same memory as compilation, and CI observability supplies the utilization numbers this section asks for. Cache configuration is covered on the Gradle build caches page, and configuration phase time on the Gradle configuration cache guide.
Configuration
Set the worker count and every heap explicitly. Defaults are derived from the machine, so a label change silently changes both, which is how a build that passed on 8 vCPU dies on 32.
Start in gradle.properties, sized for warp-ubuntu-latest-x64-8x:
org.gradle.parallel=true
org.gradle.workers.max=8
org.gradle.caching=true
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1gThen bound the test forks in the build script, because maxParallelForks defaults to 1 per test task while the fork heap defaults to a quarter of machine RAM:
tasks.withType(Test).configureEach {
maxParallelForks = 4
jvmArgs = ["-Xmx4g", "-XX:MaxMetaspaceSize=512m"]
}The workflow pins the label and repeats the worker count on the command line so a job override is visible in the diff:
name: build
on:
pull_request:
push:
branches: [main]
jobs:
build:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew build --parallel --max-workers=8
- name: Print peak heap settings
if: always()
run: ./gradlew -q javaToolchainsMaven builds follow the same budget with different flags, and the repository and toolchain setup behind them is covered on the Java builds page. mvn -T 1C runs one module thread per core, MAVEN_OPTS=-Xmx6g bounds the Maven JVM, and Surefire's forkCount with an argLine of -Xmx4g bounds the test forks. The committed heap column in the table applies unchanged: forkCount multiplied by the Surefire heap plus the Maven heap stays under two thirds of RAM.
Verify the change with the observability numbers rather than impressions. Peak memory utilization under 80 percent with CPU pinned across the run means the size fits and the heaps are correct. Peak CPU under 40 percent on an 8 vCPU runner means the build is serial somewhere, and a larger label will bill more for the same duration.
Cost or Time Model
Take a team running 900 Java builds per month at 12 billed minutes each. Rates below are the WarpBuild rates from the pricing page and the GitHub list prices from the per-minute rate reference, both checked on 2026-08-13.
| Option | Arithmetic | Monthly cost |
|---|---|---|
| GitHub-hosted Linux 8-core | 900 x 12 min x $0.022 | $237.60 |
| warp-ubuntu-latest-x64-8x | 900 x 12 min x $0.016 | $172.80 |
The label change moves the bill by $64.80 per month with the same 8 vCPU and 32GB shape and no assumption about the build getting quicker.
Now add the sizing lever. Suppose observability shows all 8 workers saturated through compilation and the test task, and a trial on 16 vCPU with workers.max=12 and 8 test forks brings the build to 7.5 minutes:
| Option | Arithmetic | Monthly cost |
|---|---|---|
| warp-ubuntu-latest-x64-16x | 900 x 7.5 min x $0.032 | $216.00 |
| GitHub-hosted Linux 16-core, same profile | 900 x 7.5 min x $0.042 | $283.50 |
At $216.00 the doubled size stays under the $237.60 GitHub-hosted starting point while returning 4.5 minutes per build, which is 4,050 minutes per month across the team's pull requests.
The break-even is arithmetic. The 16x rate is exactly double the 8x rate, so cost per run matches when duration falls to 12 x ($0.016 / $0.032) = 6.0 minutes. Between 6.0 and 12 minutes the larger runner buys time and costs more; below 6.0 minutes it wins on both. Gradle builds rarely clear that bar past 16 vCPU, because the configuration phase and the serial tail of a module graph do not shrink, so treat the 32x row as a ceiling for wide monorepos rather than a default.
FAQ
What runner size should a Gradle build start on?
Start at warp-ubuntu-latest-x64-8x, which is 8 vCPU and 32GB of memory at $0.016 per minute. That shape carries a 6g Gradle daemon heap plus four forked test JVMs at 4g each with room left for Metaspace and the page cache.
Why does my Gradle build die with exit code 137?
Exit code 137 is the kernel OOM killer. It usually means the daemon heap plus maxParallelForks multiplied by the fork heap exceeded the machine's RAM. The JVM defaults its maximum heap to one quarter of physical memory, so four unbounded forks on a 32GB runner can commit the whole machine.
Should org.gradle.workers.max match the runner vCPU count?
Match it up to 8 vCPU. Above that, the memory budget usually binds first, so 12 workers on a 16 vCPU runner and 16 workers on a 32 vCPU runner keep committed heap under two thirds of RAM.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.