OpenJDK 25.0.0–25.0.3 uses a stale AOT cache without saying so

github/youndie/zavarnik

Project Leyden's AOT cache (JEP 483 and 514) records the classes a training run loaded and serves them at the next start, and the JVM is supposed to refuse the cache when the jars on the classpath are no longer the ones it was trained against. On the first four builds of JDK 25 and the first two of JDK 26 it does not: a cache trained with -XX:AOTCacheOutput is accepted after the jar was touched, replaced or moved in the classpath, and the classes come from the cache. This is JDK-8377932, reported and fixed by Ioi Lam of the Leyden team in February 2026 and backported to 25.0.4 and 26.0.2. The bug is not my finding. What is mine is the measurement: which builds ship it, what exactly is accepted, and that -XX:AOTMode=on — the mode that is supposed to make a rejected cache fatal — does not catch it. I ran into it while building zavarnik, a Gradle plugin that trains and verifies these caches; the experiment and the logs live there.

What the check is meant to do

For every jar on the application classpath the cache stores the size and the modification time. At start-up the JVM compares both with the file it finds; a mismatch prints a warning and disables the cache:

[warning][aot] This file is not the one used while building the AOT cache: 'lib/app.jar', timestamp has changed, size has changed
[info][class,path] Archived app classpath validation: failed

That is the whole protection against running last week's classes on top of this week's jars. Under -XX:AOTMode=on a rejected cache is fatal, exit code 1; in the default mode it is three lines on stderr and a normal start without the cache.

The experiment

experiments/aot-validation/run.sh compiles a one-class application, trains a cache with the one-step workflow, and then starts the application under sixteen conditions — relocated directory, touched jar, replaced jar, extra classpath entries, an agent, a different GC. It asserts nothing. Every case ends with one line: shared is how many classes the JVM reported as loaded from the cache, file whether the application class came from the jar instead. The log is the result, and one log per JDK build is committed under results/.

Same script, same day, two builds. OpenJDK 25.0.2 on macOS:

=== R4 jar replaced by a different jar, same name
[info][class,load] App source: shared objects file
shared=895 file=0

=== R14 -XX:AOTMode=on with a replaced jar: exit code
exit=0

OpenJDK 25.0.4 on Linux:

=== R4 jar replaced by a different jar, same name
[warning][aot       ] This file is not the one used while building the AOT cache: 'lib/app.jar', timestamp has changed, size has changed
[info   ][class,path] Archived app classpath validation: failed
shared=0 file=0

=== R14 -XX:AOTMode=on with a replaced jar: exit code
exit=1

In R4 the jar named app.jar no longer contains the class App at all — it is a different jar copied over the same name. 25.0.2 starts the application anyway, with App served from the cache, and -XX:AOTMode=on, the mode whose purpose is to make a rejected cache fatal, exits 0. The line Archived app classpath validation never appears in the 25.0.2 log: the check did not fail, it did not run. A touched jar (R3) and a jar moved to the front of the classpath (R6) behave the same way. JetBrains Runtime 25.0.4.1 on the same Mac and OpenJDK 26.0.2.1 on the same Linux box reject all three, so this is the build, not the platform.

Why the check does not run

The validation is guarded by a condition in aotClassLocation.hpp: it runs only when the highest classpath index that was actually used during training reaches the application classpath. In the one-step workflow (-XX:AOTCacheOutput) the cache is assembled by a second JVM after the training run exits, and that JVM loads the classes from the recorded configuration rather than through the class loader path that updates this index. The index stays below the application entries, the condition is false, and the JVM never looks at the jars. The fix (openjdk/jdk#29728, merged 16 February 2026) carries the index over from the training run when the final archive is dumped. It reached the 25u repository on 16 March 2026 and is in jdk-25.0.4-ga, not in jdk-25.0.3-ga; in 26u it is in jdk-26.0.2-ga, not in jdk-26.0.1-ga.

Line Check runs Builds
JDK 25 no 25.0.0, 25.0.1, 25.0.2, 25.0.3
JDK 25 yes 25.0.4 and later
JDK 26 no 26.0.0, 26.0.1
JDK 26 yes 26.0.2 and later

Every GA build of JDK 25 from its release in September 2025 until 25.0.4 on 21 July 2026 is on the first row — ten months.

What it costs in a deployment

The failure is quiet by construction. A cache is trained on CI, shipped next to the jars, and nobody retrains it after a hotfix that changes one jar — or the cache is trained once and reused across builds because it "still works". On an affected build the application starts, reports no error under any -XX:AOTMode, and runs the archived version of every class the cache holds, which on a Ktor service is all of them: on a fixed build, where the count is visible, 2322 of 2322 application, Ktor, coroutines and stdlib classes came from the cache on the stand I measured (JDK 25.0.4, -Xlog:class+load). The new jar is on the classpath and unused. A cache that is silently stale is worse than no cache.

What to check yourself

Move to 25.0.4 or 26.0.2. Until then, a cache has to be verified against the jars by something other than the JVM. zavarnik does two things about it: aotTrain writes lib/app.aot.jars, the SHA-256 of every jar the cache was trained against, and aotVerify — which runs on check — compares the jars with that manifest before it starts the application at all, so a rebuilt jar fails the build with the jar's name in the message on every JDK. It also warns at configuration time when the toolchain is one of the six builds above.

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 plugin is published as a snapshot from https://reposilite.kotlin.website/snapshots (add it to pluginManagement.repositories); the current version is the latest in its maven-metadata.xml, and the README has the rest.

Without the plugin the same check is a hash of the jars written at training time and compared at start, plus one line in the start-up log that must be present on a fixed build and is absent on an affected one:

[info][class,path] Archived app classpath validation: passed

If that line is missing from -Xlog:class+path=info output, the JVM did not look.

Limitations

  • Measured with the one-step workflow (-XX:AOTCacheOutput) on OpenJDK 25.0.2 (macOS arm64), 25.0.4 (Linux x86_64), JetBrains Runtime 25.0.4.1 (macOS arm64) and OpenJDK 26.0.2.1 (Linux x86_64). The two-step workflow (-XX:AOTMode=record then create) was not run; the bug report does not restrict itself to one workflow.
  • Only the application classpath is concerned. The JVM's own lib/modules is compared by size on every build, which is why a cache from a -jdk image is refused by the -jre image of the same Temurin build — a different limitation, and a documented one.
  • The modification time is compared in whole seconds. A jar rebuilt within the same second as the training run, with the same size, passes the check on a fixed build too.
  • Not verified on Windows.

Source: github.com/youndie/zavarnikexperiments/aot-validation/ for the script and the four logs, docs/research/research-architecture.md §1.2 for the source-level trace. The same repository's second phase — why a bytecode optimiser for Ktor services was measured and not built — is in User code is 1–4 % of a Ktor service's CPU.