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
In a cluster — konekt, Ktor CIO with Exposed and Postgres under a one-core limit, the cache trained in the release pipeline inside the image that ships, the readiness probe asking every second — the pod is Ready 3 s after its container starts, against 11 s for the release before. On its stand, docker start to /health went from 4.4 s to 2.0 s and the first request from 510 ms to 240 ms at the median of ten restarts; the next hundred requests did not move.
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
No wildcard on the start script's classpath: the JVM expands lib/* in the filesystem's order, which differs between container runtimes, and a cache trained under one is refused under another with nothing on the build machine able to tell. The plugin refuses it at installDist.
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
// build.gradle.kts
plugins {
application
id("io.github.youndie.zavarnik") version "0.1.0"
}
zavarnik {
training {
readyWhen.url("http://127.0.0.1:8080/health")
workload { get("http://127.0.0.1:8080/api/items") }
}
}
0.1.0 is on the Gradle Plugin Portal, so the plugins { } block alone installs it — no repository to declare. Every push to main also publishes a snapshot, 0.1.0.<run>, to https://reposilite.kotlin.website/snapshots; the README says how to take one. ./gradlew check then trains and verifies; ./gradlew distTar ships the cache; the repository's samples/ktor/Dockerfile is the container recipe. What the plugin hides — the training, the verification and the packaging, written out as five steps by hand — is the how-to; the JDK builds that accept a stale cache have their own post. A cache is not the only way to start faster: the repository's third phase takes a CRaC checkpoint of the warmed-up process instead, which restores in 131 ms against 2 317 with its JIT already hot — and what that costs in open sockets is every socket that refuses the checkpoint.
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.