Why it exists
Project Leyden (JDK 25 and 26) makes a JVM service ready two to three times faster from an AOT cache, with no change to the code. Spring Boot and Quarkus wrapped the workflow into their own tooling; a Ktor server, an http4k service or a CLI on the plain application plugin has two commands and a list of ways the cache stops working without telling anyone. The JVM rejects a cache silently by default, exit code 0 and three lines on stderr. Four builds of JDK 25 and two of JDK 26 do not compare the cache with the jars at all (JDK-8377932), so a rebuilt jar runs last week's classes. A -jre image refuses a cache trained on the -jdk image of the same build. A zip loses the timestamps the check depends on. Each of these was measured before the plugin was written; the research document and the logs are in the repository.
What you get
aotTrainruns the installed distribution through its own start script with-XX:AOTCacheOutput, waits for a readiness URL, runs a workload of HTTP requests or commands, stops the JVM withSIGTERMand waits for the cache to be assembled. Jar timestamps are pinned to the constant Gradle's tar uses, so the cache survivesdistTarand DockerCOPY.aotVerify, oncheck, is the point. It compares the jars with a SHA-256 manifest written at training time, starts the application under-XX:AOTMode=onwhere a rejected cache is fatal, and fails unless the configured share of the application's classes came from the cache. The message names which of the three failed and why.aotReportwrites two tables: time to the first200cold and cached, medians over sorted runs, and what the JIT still compiles after the start — because the cache holds classes, heap objects and profiles, not compiled code, and a report that showed only the first table would promise a warm service the JVM does not deliver.- The start scripts pick the cache up when it is there and run without it when it is not;
distTarcarries it,distZipsays why it cannot. - A runner for the image. Every distribution carries
lib/zavarnik-runner.jar, the same training and verification on a bare JRE with no Gradle and no curl, so the-jreruntime stage of a Dockerfile trains and verifies the cache on the JVM that will use it. - Portable by default. The adapter code the JVM caches for the training CPU is switched off, so a cache trained on a CI runner with AVX-512 does not
SIGILLon a narrower machine; the cost was measured at one millisecond of readiness.
Numbers
Time to the first 200 on /health of the Ktor sample, ten runs per variant, medians, Linux x86_64, OpenJDK 25.0.4: 143 ms without the cache, 47 ms with it; on macOS arm64 with JetBrains Runtime 25.0.4.1, 98 and 37 ms. The JIT is not warmed: C2 compiles the same thousand-odd methods after the start either way, about ten per cent fewer at best. The cache for the sample is 31 MiB; the container image with a -jre runtime stage is 562 MB against 647 MB with two -jdk stages.
Requirements
JDK 25 or newer as the toolchain, preferably 25.0.4 or 26.0.2 — the plugin warns on the six builds that skip the jar check and verifies the jars itself on all of them. The application plugin. Linux or macOS for training; production on Windows works, training on it does not yet. The same JDK build in production as in training, down to the image.
Install
// settings.gradle.kts
pluginManagement {
repositories {
gradlePluginPortal()
maven("https://reposilite.kotlin.website/snapshots")
}
}
// build.gradle.kts
plugins {
application
id("io.github.youndie.zavarnik") version "<version>"
}
zavarnik {
training {
readyWhen.url("http://127.0.0.1:8080/health")
workload { get("http://127.0.0.1:8080/api/items") }
}
}
The current version is the latest in the plugin's maven-metadata.xml. ./gradlew check then trains and verifies; ./gradlew distTar ships the cache; the repository's samples/ktor/Dockerfile is the container recipe.
The repository also holds a second phase that was measured and not built: a Kotlin bytecode optimiser, closed because user code owns 1–4 % of a Ktor service's CPU — the write-up.