DEV Community

Cover image for ImNotOkay, a GC experiment for Android CI builds
Iñaki Villar
Iñaki Villar

Posted on

ImNotOkay, a GC experiment for Android CI builds

Inspired by a very specific early-2000s song, I’m presenting a new garbage collector policy for Android CI builds: ImNotOkayGC

org.gradle.jvmargs=-Xmx5g -XX:+UseImNotOkayGC
Enter fullscreen mode Exit fullscreen mode

This collector is meant for large Android builds, where memory pressure quietly builds up across tasks, variants, and modules, and when memory finally runs out, instead of pretending everything is fine, it tells you exactly how bad things really are.

[GC] I’m fine.
:app:generateReleaseRFile FROM-CACHE    
:app:compileReleaseKotlin   
[GC] Actually I’m not fine.
[GC] Full GC.
[GC] It didn’t help.
Enter fullscreen mode Exit fullscreen mode

Funny, right? Consider this my small April Fools contribution.

Jokes aside, I wanted to try something more serious.

Ephemeral Android CI Builds

This is an idea I had wanted to try for a long time: Gradle builds have a very well-defined lifecycle.

Android CI builds are not long-lived services. They are ephemeral workloads with recognizable phases: startup, rising memory pressure, heavy execution, and then termination. Even if the exact task graph changes between assembleDebug and assembleRelease, the overall shape is still much more structured than a backend service running for months or supporting thousands of requests per minute.

And yet we use the same general-purpose garbage collectors for both worlds.

Of course, we can tune JVM arguments, but that usually turns into trial and error. What made this experiment different is that I used Codex to push the idea much further. Instead of stopping at flag tuning, I was able to build an iterative workflow: capture a baseline, profile the build with JFR, look for recurring allocation and GC patterns across projects, modify the JDK, rerun the scenarios, and compare the results.

That process is probably the most interesting part of this article.

The original idea was simple: if Android CI builds have such a specific workload shape, could we get something closer to a GC policy designed for that kind of execution? Not a completely different collector, but a G1-based policy that reacts better to the memory pressure patterns we see in ephemeral Android builds.

The Experiment

The first step was to create a stable baseline. I forked the JDK, built a custom JDK 23 distribution, and used standard G1 in both the Gradle and Kotlin daemons so I could compare later policy changes against a consistent starting point.

From there, the workflow became iterative:

  1. Run a representative set of Android builds using the published JDK 23 baseline build with standard G1.
  2. Capture telemetry, especially JFR and GC data.
  3. Identify common patterns in memory pressure and pause behavior.
  4. Modify the GC policy in the JDK.
  5. Rerun the same scenarios with the updated build.
  6. Compare again, then go back to step 4.

For each project, I tested two tasks, assembleDebug and assembleRelease, under two execution modes: non-warm runs on fully clean agents, and warm runs that reused the Gradle user home while excluding task output cache artifacts.

Each variant was executed 10 times, and every run generated the full set of profiling artifacts, including JFR recordings, GC data, and build metrics. That gave me something more reliable than a single build result. Instead of looking at isolated executions, I could compare repeated runs and see which patterns actually held.

Codex played a major role in making that process practical. It helped me move faster through the mechanical parts of the experiment, from changing JDK behavior to wiring the runs and analyzing the outputs, so I could spend more time on the observations and less time on setup.

If you are curious, here is an example of a warm baseline execution for one of the projects:
https://github.com/cdsap/im-not-ok-metrics/actions/runs/23775355907

Each run produced a fairly detailed profiling summary. A single example looked like this:

# Build Profiling Summary

- Build duration: 392.00s
- Build exit code: 0
- JDK: 23-internal-adhoc.runner.jdk
- Gradle: 9.4.0
- Build scan: https://gradle.com/s/vajkodzs7myja
- Declared GC profiles: gradle-daemon=openjdk-default, kotlin-daemon=repo-default, test-jvm=repo-default
- Reported collector labels: gradle-daemon=openjdk-default, kotlin-daemon=repo-default

## Per-process highlights

- gradle-daemon (pid 2901): max RSS 4304416.0 kB, avg RSS 2484696.2151898732 kB, max CPU 285.0%, collector openjdk-default, runtime GC G1, alloc mode n/a, alloc rate n/a MB/s, GC p95 112.87134999999998 ms, GC max 380.607 ms, total GC 25887.478 ms

- kotlin-daemon (pid 13382): max RSS 2422960.0 kB, avg RSS 2040902.820143885 kB, max CPU 302.0%, collector repo-default, runtime GC G1, alloc mode n/a, alloc rate n/a MB/s, GC p95 193.8508499999999 ms, GC max 239.578 ms, total GC 8645.073 ms

## Correlated peaks

- gradle-daemon pid 2901 peaked near :app-scaffold:assembleDebug at 2026-03-31T02:34:15Z
- kotlin-daemon pid 13382 peaked near :app-scaffold:extractDebugAnnotations at 2026-03-31T02:34:06Z
- gradle-worker pid 14199 peaked near :libraries:util:checkKotlinGradlePluginConfigurationErrors at 2026-03-31T02:31:16Z
- gradle-worker pid 14222 peaked near :libraries:util:checkKotlinGradlePluginConfigurationErrors at 2026-03-31T02:31:16Z
- gradle-worker pid 16210 peaked near :app-scaffold:extractDebugAnnotations at 2026-03-31T02:34:00Z

## Artifacts

- Metadata: /home/runner/work/im-not-ok-metrics/im-not-ok-metrics/project/artifacts/20260331T022747Z/metadata.json
- GC logs: /home/runner/work/im-not-ok-metrics/im-not-ok-metrics/project/artifacts/20260331T022747Z/logs/gc
- JFR: /home/runner/work/im-not-ok-metrics/im-not-ok-metrics/project/artifacts/20260331T022747Z/logs/jfr
- OS metrics: /home/runner/work/im-not-ok-metrics/im-not-ok-metrics/project/artifacts/20260331T022747Z/logs/os/process_metrics.csv
Enter fullscreen mode Exit fullscreen mode

What Changed in ImNotOkay

After analyzing the baseline data, Codex proposed a set of changes based on the idea that Android Gradle builds on ephemeral agents behave differently from long-running JVM applications.

The idea behind ImNotOkay is to make G1 a bit more aware of the build phase instead of treating the whole build the same way from start to finish.

At the beginning, during roughly the first minute, it stays out of the way and lets Gradle run normally so the configuration phase can move as fast as possible.

It only starts intervening once the build shows real signs of memory pressure. At that point, it tries to keep memory behavior more controlled by preventing the young generation from growing too aggressively.

The goal is not to make G1 more restrictive all the time, but to react when the build starts entering a heavier phase. And if that pressure continues for longer, the policy gradually relaxes again so the build can recover throughput instead of staying constrained for too long.

You can see the complete diff here:
https://github.com/cdsap/jdk/compare/imnotokay-jdk23-baseline...cdsap:jdk:im-not-okay

Results

I should also be honest about the outcome: at least for now, this was a failed attempt:

Project Config Mode Duration Δ Gradle GC p95 Δ Gradle GC total Δ
android-nowinandroid assemble-debug nonwarm 🔴 +1.24% 🔴 +5.07% 🔴 +2.10%
android-nowinandroid assemble-debug warm 🟢 -2.45% 🟢 -13.60% 🔴 +3.21%
android-nowinandroid assemble-release nonwarm 🟢 -1.61% 🔴 +21.51% 🔴 +14.69%
android-nowinandroid assemble-release warm 🔴 +2.71% 🔴 +6.41% 🔴 +4.24%
CatchUp assemble-debug nonwarm 🟢 -1.20% 🔴 +5.48% 🔴 +9.06%
CatchUp assemble-debug warm 🔴 +2.32% 🟢 -22.89% 🔴 +10.96%
CatchUp assemble-release nonwarm 🟢 -0.36% 🔴 +7.43% 🔴 +1.63%
CatchUp assemble-release warm 🔴 +1.38% 🔴 +12.89% 🔴 +24.01%
GeneratedProjectMedium assemble-debug nonwarm 🔴 +1.10% 🔴 +3.67% 🔴 +13.79%
GeneratedProjectMedium assemble-debug warm 🔴 +0.79% 🔴 +1.75% 🟢 -7.34%
GeneratedProjectMedium assemble-release nonwarm 🟢 -1.01% 🟢 -1.65% 🟢 -1.14%
GeneratedProjectMedium assemble-release warm 🟢 -1.60% 🔴 +4.04% 🔴 +6.93%
GeneratedProjectSmall assemble-debug nonwarm 🟢 -0.35% 🔴 +16.19% 🟢 -2.62%
GeneratedProjectSmall assemble-debug warm 🔴 +2.60% 🟢 -25.00% 🟢 -11.13%
GeneratedProjectSmall assemble-release nonwarm 🔴 +2.78% 🟢 -4.02% 🟢 -0.79%
GeneratedProjectSmall assemble-release warm 🔴 +0.15% 🟢 -0.70% 🟢 -0.67%
GeneratedProjectLarge assemble-debug warm 🔴 +0.27% 🟢 -13.34% 🟢 -12.34%
GeneratedProjectLarge assemble-release nonwarm 🟢 -1.14% 🔴 +7.50% 🟢 -0.36%
GeneratedProjectLarge assemble-release warm 🟢 -2.37% 🔴 +11.73% 🟢 -0.28%

The results were not strong enough to justify presenting this as “here is a better GC policy for Android CI.” The behavior was too mixed and inconsistent to call it a success. So I ended up closing the experiment earlier than I expected, partly because I wanted to publish something today and the data did not support a stronger conclusion.

Even if the collector changes did not clearly win, the process itself was still valuable. Workload tracing, JFR profiling, iterative JDK changes, and fast experimentation with Codex let me push the idea much further than I normally would. So while this is not a success story in terms of results, it still feels like a good example of how far this kind of experimentation can go.

Using the new ImNotOkay Collector Policy

If you made it this far and still want to try it, the funny thing is that it is totally possible. You can use the new policy with:

org.gradle.jvmargs=-XX:+UnlockExperimentalVMOptions -XX:+UseImNotOkayGC ...
Enter fullscreen mode Exit fullscreen mode

And you would also need to use the custom JDK in your project:

- name: Download custom JDK artifact
  env:
    GH_TOKEN: ${{ github.token }}
  run: |
    gh run download 23719062741 \
      --repo cdsap/im-not-ok-metrics \
      --name custom-jdk-linux-9ad2e63f1763 \
      --dir "$GITHUB_WORKSPACE/custom-jdk"

- name: Unpack and activate custom JDK
  run: |
    mkdir -p "$GITHUB_WORKSPACE/custom-jdk/unpacked"
    tar -xzf "$GITHUB_WORKSPACE/custom-jdk/custom-jdk-linux-9ad2e63f1763.tar.gz" \
      -C "$GITHUB_WORKSPACE/custom-jdk/unpacked"
    echo "JAVA_HOME=$GITHUB_WORKSPACE/custom-jdk/unpacked/jdk" >> "$GITHUB_ENV"
    echo "$GITHUB_WORKSPACE/custom-jdk/unpacked/jdk/bin" >> "$GITHUB_PATH"

- name: Build
  run: ./gradlew assembleDebug
Enter fullscreen mode Exit fullscreen mode

Final words

Even if the collector changes did not clearly win, the process itself was promising. So while this is not a success story in terms of results, it still feels like a good example of how far this kind of experimentation can go.

And yes, if you are wondering about the name, ImNotOkay came from the song that happened to be playing when I needed one. There is no deeper meaning behind it. The next songs in the playlist were Lateralus by Tool and People of the Sun by Rage Against the Machine, so this was probably the safest outcome.

Top comments (0)