Nucleus
Performance & native

AOT cache — JDK 25's near-instant cold start

Project Leyden's AOT cache, generated automatically at build time and bundled with your installer. One Gradle flag, ~1 s cold boot with a fully-tiered JIT ready to compile.

JVM desktop apps used to pay a cold-start tax: thousands of classes loaded, bytecode verified, hot paths JIT-compiled before the UI feels responsive. This was the original reason Nucleus exists. JDK 25 fixes it with Project Leyden's single-step AOT cache — classes, profiles, and code pre-warmed offline, replayed at launch. Nucleus generates that cache during your build and bundles it with the installer.

TL;DR

  • One Gradle flag: enableAotCache = true.
  • Cold start drops to ~1.2 s — HotSpot C2 is still your JIT, but warmup is gone.
  • The cache is generated by running your app once in training mode during the build, then bundled into every distribution.
  • nucleus.aot-runtime lets your code branch on training vs runtime mode.

Install

nucleus.aot-runtime is brought in transitively by nucleus.nucleus-application. Add it explicitly if you don't use the umbrella:

dependencies {
    implementation("dev.nucleusframework:nucleus.aot-runtime:<version>")
}

Quickstart

// build.gradle.kts
nucleus.application {
    mainClass = "com.example.MainKt"
    nativeDistributions {
        enableAotCache = true
    }
}

JDK 25+ required

AOT cache generation requires JDK 25 or later. Older toolchains fail the build.

That's the entire build side. The plugin will:

  1. Launch your app in training mode after createDistributable.
  2. Record class loading and JIT compilation into app.aot (-XX:AOTCacheOutput).
  3. Inject -XX:AOTCache=app.aot into the launcher.
  4. Bundle the cache with every installer (.dmg, .msi, .deb, …).

Training mode — your app must self-terminate

During training, the JVM watches a real run of your app. Your code must exit cleanly so the build doesn't hang on the 300 s safety timeout. With nucleusApplication { … }, this is one line:

import dev.nucleusframework.application.aotTraining
import dev.nucleusframework.application.nucleusApplication
import kotlin.time.Duration.Companion.seconds

fun main(args: Array<String>) = nucleusApplication(args) {
    // No-op outside training. Calls exitApplication() after 45 s.
    aotTraining(duration = 45.seconds)

    if (isAotTraining) {
        // The longer the training run touches real screens, the better the
        // cache. Walk the navigation graph, preload fonts and images.
        preloadNavigationScreens()
        preloadFontsAndImages()
    }

    DecoratedWindow(onCloseRequest = ::exitApplication, title = "MyApp") {
        App()
    }
}

If you call Compose's application { … } directly, drive the timer manually:

import dev.nucleusframework.aot.runtime.AotRuntime

fun main() {
    if (AotRuntime.mode() == AotRuntimeMode.TRAINING) {
        Thread({ Thread.sleep(45_000); System.exit(0) }, "aot-timer").start()
        preloadNavigationScreens()
        preloadFontsAndImages()
    }
    application { /* ... */ }
}

How it works

The plugin sets the nucleus.aot.mode system property to training during cache generation and runtime when the cache is loaded. AotRuntime.mode() reads that property and returns AotRuntimeMode.TRAINING, RUNTIME, or NONE.

The training run must:

  • Exit with code 0 (the aotTraining DSL handles this).
  • Finish before the 300 s safety timeout.
  • Be representative of a real user session — the more real screens you touch, the more methods get profiled, the faster the post-cache cold start.

On headless Linux, the plugin spins up Xvfb automatically.

Platform & JDK pinning

The AOT cache is platform- and JDK-specific. A cache built on macOS ARM64 with JBR 25.0.2 will not work on Linux x64 or with another JDK build.

  • Build on each target platform — the cleanest setup is a CI matrix.
  • The plugin always uses the bundled JDK from createDistributable to train, so the runtime JDK matches the training JDK byte-for-byte.
  • setup-nucleus pins the same JBR version across OS runners. See CI/CD.

Reference

Gradle DSL

PropertyDefaultNotes
nativeDistributions.enableAotCachefalseSingle switch. Triggers training + bundling.

AotRuntime

APIReturns
AotRuntime.mode()AotRuntimeMode.TRAINING, RUNTIME, or NONE

nucleusApplication scope

PropertyEquivalent
aotModeAotRuntime.mode()
isAotTrainingaotMode == TRAINING
isAotRuntimeaotMode == RUNTIME
aotTraining(duration, onTimeout?)Self-exit timer for training runs

Measurement methodology

When you compare cold-start numbers, measure from the OS-level process spawn (time ./MyApp on Linux/macOS, Measure-Command on Windows) to first-frame visible. The 1.2 s figure on the landing comes from a stock JBR 25 startup with the AOT cache primed by a 45 s training run that walks the home screen and one settings tab.

Notes

  • The cache is per binary — every release ships a fresh one. Don't try to share caches across versions.
  • Use the GraalVM native image path instead if you need sub-100 ms cold start or a tiny resident set. AOT cache wins on throughput; GraalVM wins on startup and size.