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-runtimelets 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:
- Launch your app in training mode after
createDistributable. - Record class loading and JIT compilation into
app.aot(-XX:AOTCacheOutput). - Inject
-XX:AOTCache=app.aotinto the launcher. - 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
aotTrainingDSL 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
createDistributableto train, so the runtime JDK matches the training JDK byte-for-byte. setup-nucleuspins the same JBR version across OS runners. See CI/CD.
Reference
Gradle DSL
| Property | Default | Notes |
|---|---|---|
nativeDistributions.enableAotCache | false | Single switch. Triggers training + bundling. |
AotRuntime
| API | Returns |
|---|---|
AotRuntime.mode() | AotRuntimeMode.TRAINING, RUNTIME, or NONE |
nucleusApplication scope
| Property | Equivalent |
|---|---|
aotMode | AotRuntime.mode() |
isAotTraining | aotMode == TRAINING |
isAotRuntime | aotMode == 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.
Tasks & CI
Gradle tasks added by the Nucleus plugin, output locations, and a GitHub Actions matrix that caches the native-image build.
Native access — reflection metadata, resolved for you
Nucleus auto-injects reflect-config, resource-config, and JNI metadata for every Nucleus module and most idiomatic Kotlin libraries — so your GraalVM native build works without writing JSON.