Every Android build spins up more JVM processes than most developers realize. Beyond the Gradle wrapper, there’s the main Gradle process orchestrating the build and several child processes created for specific tasks. These include test processes that execute in isolation, optional separate JVMs for tools like Lint or R8 when configured to run out of process, individual Java tasks that may also run in their own JVM, and the Kotlin compiler process, which delegates all Kotlin compilation units to a dedicated Kotlin daemon:
Understanding how these processes interact helps uncover hidden inefficiencies that affect both memory usage and build performance, especially in CI environments.
In general, these child processes terminate once their associated task has completed. However, that’s not the case for the Kotlin process. If we inspect the running JVMs after a build finishes, we can still see both the main Gradle process and the Kotlin daemon alive — even after all compilation work has ended (except when using --no-daemon):
3564 GradleDaemon
15564 KotlinCompileDaemon
This behavior is intentional. The Kotlin daemon stays alive to speed up incremental builds by avoiding the startup overhead of a new compiler process. While that optimization is useful for local development, it provides little benefit in CI environments, where builds are typically clean and short-lived.
In Android builds, which are often highly modularized, the Kotlin compiler is required across all modules that include Kotlin sources. Because of this, the Kotlin process remains active throughout the compilation of all modules. The last compilation unit is typically the main entry point or Android application module, and these modules tend to be heavier, often running additional demanding tasks after Kotlin compilation. This makes it a natural point where the Kotlin process is no longer needed, and releasing its memory can benefit the tasks that follow. This detail can be crucial in environments that are close to their available memory threshold, where freeing the Kotlin process at the right moment can prevent OOM errors and improve overall build stability.
The main goal of this article is to experiment and measure the impact of changing this process behavior, based on the idea that a persisted process is not required once it has completed all the tasks associated with it.
R8 Tasks
In this analysis, we focus specifically on release builds. These builds execute the R8 task, whose main responsibility is to shrink, obfuscate, and optimize the app components that will be packaged into the final binary. This task plays a critical role in Android builds and is often a major contributor to build duration due to its computational cost.
An interesting aspect of R8 is that, by design, it runs at the very end of the build process, once all other compilation and linking phases have been completed:
Because of this sequencing, there’s no overlap between R8 and the Kotlin compiler within the same build variant. Once Kotlin compilation has finished, R8 operates independently, processing bytecode, resources, and dependencies. This means that the Kotlin process, still alive at this point, isn’t performing any work and can safely be terminated:
The goal is to free unnecessary memory before R8 executes, ensuring the system has as much available memory as possible for the final phase of the build. While this doesn’t directly address R8’s primary bottleneck, its CPU-bound processing, it introduces a secondary hypothesis worth testing:
tasks may execute faster in more isolated process environments, where memory and CPU resources face less contention.
How to terminate the process
The implementation is straightforward. We implemented a ValueSource that, through an injected ExecOperations, executes the command used to kill the existing Kotlin processes:
override fun obtain(): String {
...
return try {
execOperations.exec {
try {
commandLine("sh", "-c", parameters.commands.get())
} catch (ignored: Exception) {
}
}
...
}
This allowed us to create a new provider with the value source and the termination command:
val provider = project.providers.of(KillKotlinCompileDaemonValueSource::class.java) {
parameters.commands.set(DEFAULT_COMMAND)
}
// Terminate Kotlin processes:
const val DEFAULT_COMMAND =
"jps | grep -E \"KotlinCompileDaemon\" | awk '{print \$1}' | xargs -r kill -9"
Then, we have created a task that receives the provider as input and executes the command during the task action:
abstract class KillKotlinCompileDaemonTask : DefaultTask() {
@get:Input
abstract val kotlinDaemonKillInfo: Property<String>
@TaskAction
fun killDaemons() {
kotlinDaemonKillInfo.get()
logger.lifecycle("Kill Kotlin compile daemon command executed")
}
}
Finally, we wire up when we want to execute the termination process. We register our task and make it run after the Kotlin compile task in the main application module.
Simple demonstration
How does this look in practice? If we analyze only the memory allocation of build child processes, we see that after our task runs and kills the Kotlin process, the impact is clear:
There is also a bigger benefit. We are not just releasing the heap allocation of the Kotlin process, we are freeing the entire memory footprint of that process and giving it back to the operating system:
The same applies in environments where Kotlin versions are not aligned and multiple Kotlin processes exist:
In summary, we are successfully returning the Kotlin process memory back to the OS and creating a lighter environment before R8 execution. Next, we analyze the results of the experiments.
Experiment environment
Because we want to measure the impact in CI builds, we set the entire experiment in GitHub Actions.
We selected three different projects:
- nowinandroid: https://github.com/android/nowinandroid
- A synthetic project with 120 modules created by ProjectGenerator: https://github.com/cdsap/androidRectangle120modules
- Signal Android app: https://github.com/signalapp/Signal-Android
Each project has two variants: the default main configuration, and a variant applying the R8Booster plugin in the Android application module. This plugin provides the task that terminates the Kotlin process right after the Kotlin compilation phase.
For the iterations, we ran one warm-up build to download dependencies and initialize the Gradle User Home, followed by 100 iterations of the assembleRelease task on fresh agents, reusing only dependencies and transforms from the cache.
The tasks under experiment were:
nowinandroid:
:app:assembleProdReleasesynthetic project:
assembleReleaseSignal Android:
:Signal-Android:assemblePlayProdRelease
Results Experiment
After preparing the environment and running 100 iterations per variant, we aggregated the results to compare build time, R8 task duration, and peak memory usage.
The following table shows the results of the experiments for the main metrics across all projects, based on the mean values:
| Project | Build Time Improvement | R8 Task Improvement | Max Memory Reduction | Notes |
|---|---|---|---|---|
| Now in Android | 0.0% | 1.5% | 14.7% | Minimal time change, strong memory reduction |
| Synthetic Project (120 modules) | 1.7% | 5.6% | 13.3% | Moderate, consistent gains across all metrics |
| Signal Android App | 3.1% | 7.0% | 14.5% | Best overall results, closest to a real project scenario |
Terminating the Kotlin process after compilation had no negative impact on build performance and noticeably reduced memory usage across all projects.
The Signal Android app, representing a real-world scenario, achieved the best overall gains, with build times 3% faster, R8 7% faster, and memory reduced by about 15%.
nowinandroid
Build Scans:
Build time
| Metric | main | Terminate Kotlin Process | Diff | Diff % |
|---|---|---|---|---|
| mean | 283 | 283 | 0 | 0.0 |
| median | 283 | 283 | 0 | 0.0 |
| p90 | 294 | 292 | 2 | 0.7 |
R8 Task
| Metric | main | Terminate Kotlin Process | Diff | Diff % |
|---|---|---|---|---|
| mean | 135 | 133 | 2 | 1.5 |
| median | 135 | 133 | 2 | 1.5 |
| p90 | 140 | 137 | 3 | 2.2 |
Peak Build Memory
| Metric | main | Terminate Kotlin Process | Diff | Diff % |
|---|---|---|---|---|
| mean | 8.69 | 7.41 | 1.28 | 14.7 |
| median | 8.69 | 7.38 | 1.31 | 15.1 |
| p90 | 8.81 | 7.72 | 1.09 | 12.4 |
Synthectic project 120 modules
Build Scans:
Build time
| Metric | main | Terminate Kotlin Process | Diff | Diff % |
|---|---|---|---|---|
| mean | 654 | 643 | 11 | 1.7 |
| median | 652 | 643 | 9 | 1.4 |
| p90 | 676 | 660 | 16 | 2.4 |
R8 Task
| Metric | main | Terminate Kotlin Process | Diff | Diff % |
|---|---|---|---|---|
| mean | 90 | 85 | 5 | 5.6 |
| median | 90 | 85 | 5 | 5.6 |
| p90 | 95 | 89 | 6 | 6.3 |
Peak Build Memory
| Metric | main | Terminate Kotlin Process | Diff | Diff % |
|---|---|---|---|---|
| mean | 11.77 | 10.20 | 1.57 | 13.3 |
| median | 11.72 | 10.19 | 1.53 | 13.1 |
| p90 | 12.07 | 10.54 | 1.53 | 12.7 |
Signal Android
Build Scans:
Build time
| Metric | main | Terminate Kotlin Process | Diff | Diff % |
|---|---|---|---|---|
| mean | 604 | 585 | 19 | 3.1 |
| median | 601 | 583 | 18 | 3.0 |
| p90 | 626 | 600 | 26 | 4.2 |
R8 Task
| Metric | main | Terminate Kotlin Process | Diff | Diff % |
|---|---|---|---|---|
| mean | 215 | 200 | 15 | 7.0 |
| median | 215 | 200 | 15 | 7.0 |
| p90 | 225 | 207 | 18 | 8.0 |
Peak Build Memory
| Metric | main | Terminate Kotlin Process | Diff | Diff % |
|---|---|---|---|---|
| mean | 15.38 | 13.15 | 2.23 | 14.5 |
| median | 15.39 | 13.13 | 2.26 | 14.7 |
| p90 | 15.45 | 14.13 | 1.32 | 8.5 |
Final Words
The results show a positive impact. In the project closest to a real application, we saw the best gains in memory reduction and measurable decreases in R8 and overall build times. This confirms the expectation that releasing the Kotlin compiler process returns OS memory in full and can help builds that have heavier Kotlin phases. In practice, this translates into a lower peak memory usage, which, when close to system thresholds, can be the difference between a successful build and an out-of-memory error in CI environments.
While we focused entirely on the R8 tasks, you may want to experiment with this approach in other scenarios where the Kotlin compiler is no longer required, but a heavier post-compilation task follows, such as Java compilation.
Remember that this approach was tested only for single-variant release builds. If you want to apply the same idea elsewhere, you will need to adjust the orchestration for when the Kotlin process should be terminated based on your project’s requirements.
Happy building!















Top comments (0)