Java 25 with an AOT cache, measured

The API moves to Java 25 with an AOT cache trained at build time: ready in 3.5 s instead of 4.2 s on one vCPU, for 47 MiB more memory at idle.

Status
Accepted 3 October 2026 · amends ADR 0003
The record
0009-java-25-with-an-aot-cache.md

Context

ADR 0003 put the API on Java 21 with a dynamic class-data-sharing archive to shorten cold starts. Since then:

  • JDK 25 is the current LTS. Project Leyden's ahead-of-time cache (JEPs 483, 514, 515) supersedes dynamic CDS: a training run records the classes it loaded and linked, and the method profiles it gathered.
  • JEP 491 (JDK 24) ended carrier pinning by synchronized, which changes what the threads experiment shows.
  • JDK 27 (GA 15 September 2026) makes G1 the default even below two CPUs and 1792 MB (JEP 523) and turns compact object headers on by default (JEP 534). The Dockerfile pinned Serial on the opposite assumption.

Cloud Run scales to zero, so startup is latency a visitor sees; the instance has one vCPU and 512 MiB.

Decision

  • Build and run on Java 25 (Temurin). The Gradle toolchain provisions it, so a build does not depend on the machine's JDK.
  • Replace the CDS archive with an AOT cache trained at image build time (-XX:AOTCacheOutput with spring.context.exit=onRefresh).
  • Keep every runtime flag in one argument file, api/jvm.args, which the training run and the server both read: a cache is only valid for the flags that created it.
  • Flags, each backed by the table below: Serial, compact object headers, an initial heap of 25% (new), the heap capped at 75%, exit on OutOfMemoryError.
  • JDK 27 is not shipped: it is not an LTS. The benchmark is re-run, and the collector re-decided, when the next LTS arrives. (CI ran the tests on JDK 27 as well until 2026-11-09, when the owner took it out to keep CI light.)

Measurements

./gradlew startupBenchmark (source in api/src/bench) packages the same application into each variant and starts it ten times as a fresh container with --cpus=1 --memory=512m. JVM ready is the JVM's uptime when Spring reports the application started. The allocation-heavy request is the iterative Fibonacci of 200,000, which creates about 200,000 ever larger BigIntegers: median of four after one warm-up, over the first three starts, with the collections it caused read from the service's own telemetry. Run on 2026-10-03 with Docker Desktop 29 (WSL 2) on a 16-thread laptop: the absolute numbers are not Cloud Run's, the comparisons are what decided. The Java 21 row is the image built from the Java 21 sources that morning, which today's sources can no longer rebuild; the production row is the image api/Dockerfile builds, measured as it ships. Two earlier runs that day agree within about 5%. Ranges, three more columns and the raw samples: docs/measurements/startup-2026-10-03-0224.md.

Variant (medians of 10 starts) JVM ready (s) Memory idle (MiB) Heavy request (ms) Collections per request Memory after it (MiB)
Java 21, CDS archive, Serial: before 4.23 123 341 731 151
Java 25, no archive, Serial 5.81 150 323 681 151
Java 25, CDS archive, Serial 4.39 121 462 4,185 131
Java 25, AOT cache, Serial 3.56 137 454 4,190 142
… plus compact object headers 3.61 138 450 4,172 148
… plus an initial heap of 25% 3.56 161 346 256 168
The production image (those flags, from api/jvm.args) 3.53 170 342 256 181
Java 25, AOT cache, G1, compact headers 3.63 160 545 95 274
… plus an initial heap of 25% 3.59 178 513 61 323
Java 27, AOT cache, Serial (compact headers by default) 3.40 140 337 1,096 142
… plus an initial heap of 25% 3.39 167 505 256 175
Java 27, AOT cache, G1 (the default, JEP 523) 3.53 161 522 73 285
… plus an initial heap of 25% 3.42 175 526 67 297

What the numbers say

  1. The cache is the startup win, not the JDK. Java 25 with the old archive starts like Java 21 (4.39 s against 4.23 s). The AOT cache brings it to 3.56 s: 16% faster than the image it replaces, 39% faster than no archive.
  2. The cache exposed a heap-sizing trap. With the cache, startup allocates so little that no collection ever grows the heap beyond its initial 1/64 of the container (8 MiB). The allocation-heavy request then ran a third slower than on Java 21 and cost 4,190 young collections, 468 ms of collector time. Serial grows the heap only after a full collection, and a workload of short-lived objects never triggers one. Starting at a quarter of the container brings the request back to Java 21's time (342 ms against 341) with 256 collections, for about 30 MiB more resident memory, in an instance Cloud Run bills at 512 MiB either way.
  3. Serial stays. On one vCPU, G1 starts no faster and runs the heavy request about half again as slowly (513–545 ms against 346), although it collects far less often (61–95 collections against 256): its barriers and concurrent work share the only core. It also holds 1.6 to 1.9 times the memory afterwards. On JDK 27 the default G1 measured 522–526 ms.
  4. Compact object headers cost nothing here and leave 12% less heap in use after startup (21.2 → 18.6 MiB).
  5. Java 27 starts a little faster still (3.39–3.53 s), but its heavy-request times split between about 330 and 510 ms from one start to the next, with the same flags, for reasons not investigated. That is a reason to measure again at the next LTS, not to move now.

Not measured, rejected on reasoning: C1-only compilation (-XX:TieredStopAtLevel=1) would shorten warm-up but cap the CPU-bound experiments' peak; GraalVM native image, now that Oracle has moved Java's startup work to Leyden (research).

Consequences

  • The production image is ready in 3.53 s instead of 4.23 s (JVM uptime), and answers its first request 4.25 s after docker run instead of 4.89 s. To be re-measured on Cloud Run after the first deploy.
  • Resident memory is 170 MiB idle instead of 123, and 181 MiB after a heavy request instead of 151. The image is about 60 MB larger: the cache is 67 MB.
  • The cache is retrained with every image, so flags and JDK cannot drift from it. A cache that does not match its JVM is ignored with a warning; -XX:AOTMode=on turns that into a startup failure, which CI uses to check the image.
  • The threads experiment's pinned preset no longer pins on this server; it becomes a before-and-after comparison with a Java 21 recording.