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

> On JDK 25 before 25.0.4 and JDK 26 before 26.0.2 the JVM does not compare an AOT cache with the jars it was trained against: replace a jar, keep the cache, and the old classes run. The experiment, the logs, and what to check yourself.

Page: https://kotlin.website/blog/stale-aot-cache-on-jdk25 · Published: 2026-09-07

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](https://bugs.openjdk.org/browse/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](https://github.com/youndie/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:

```text
[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`](https://github.com/youndie/zavarnik/tree/main/experiments/aot-validation)
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:

```text
=== 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:

```text
=== 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](https://github.com/openjdk/jdk/pull/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` compares the jars with that
manifest before it starts the application at all, on every JDK. A cache shipped next to jars it
was not trained against — the case this post is about — fails `aotVerify` with the jar's name in
the message:

```text
zavarnik: the jars are not the ones app.aot was trained against:
    - lib/config-1.4.9.jar: changed since aotTrain
```

Inside a Gradle build the same divergence is closed earlier and silently rather than reported:
`aotVerify` runs on `check` behind `aotTrain`, so editing a source file retrains the cache and
the build passes. The manifest is what stands between a cache that has already been shipped and
the jars beside it. The plugin also warns at configuration time when the toolchain is one of the
six builds above.

```kotlin
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](https://plugins.gradle.org/plugin/io.github.youndie.zavarnik),
so that block is the whole installation; every push to `main` also publishes a snapshot to
`https://reposilite.kotlin.website/snapshots`, and the README says how to take one.

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:

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

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

## A rejection no verification can see — added 2026-09-08

The first cache zavarnik shipped to a cluster was refused there, on a fixed JDK build, for a
reason no check on the build machine can reproduce. The JVM records the classpath string it was
started with and compares it at the next start. The application's start script had `lib/*` on
it, and the JVM expands a wildcard in whatever order the filesystem hands back: Docker's overlay2
on the CI runner that trained the cache put `ktor-http-cio` first, the k0s node's containerd put
`packages-shared-api` first, and the node's JVM printed

```text
[warning][aot] The name of app classpath [1] does not match: expected '/opt/konekt/lib/ktor-http-cio-jvm-3.5.2.jar', got '/opt/konekt/lib/packages-shared-api-jvm-0.1.0-SNAPSHOT.jar'
[error][aot] Unable to map shared spaces
```

and started without the cache. The application was fine; the cache was dead weight. A
verification that starts the application where the training ran — which is the only place a
build has — sees the same order and passes. So the plugin now refuses a wildcard on the start
script's classpath at `installDist`. With the jars listed the same service runs on its cache:
`docker start` to the first `200` on `/health` went from 4.4 s to 2.0 s at the median of ten
restarts. Its pod is Ready 3 s after the container starts against 11 s for the release before,
but that pair is not a clean comparison — the same rollout gave readiness a startup probe every
second and took away its five-second initial delay, so part of those eight seconds is the
polling and not the cache.

## Re-run 2026-09-08

Every case above was run again from scratch on the day this note was added, on the same four
builds. It reproduced: 25.0.2 starts on a cache whose jar was replaced (`shared=895 file=0`),
`-XX:AOTMode=on` exits 0 there, and the line `Archived app classpath validation` appears nowhere
in its log; 25.0.4, 26.0.2.1 and JetBrains Runtime 25.0.4.1 reject the same cache; the stand
again served 2322 of 2322 application, Ktor, coroutines and stdlib classes from the cache.
Corrected in the text: the readiness figures above now name the probe change they ride on, and
the sentence on `aotVerify` used to say a rebuilt jar fails the build — it does not, `aotTrain`
retrains first.

## 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/zavarnik](https://github.com/youndie/zavarnik) —
`experiments/aot-validation/` for the script and the four logs,
`docs/research/research-architecture.md` §1.2 for the source-level trace. The how-to this bug is
one section of — what the cache gives a Ktor service and how to train, verify and ship it — is
[Leyden AOT cache for a plain Kotlin/JVM service](https://kotlin.website/blog/leyden-aot-cache-for-a-plain-kotlin-jvm-service).
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](https://kotlin.website/blog/user-code-share-of-a-ktor-service).
