# 08 — What actually helps ← prev [07 — Failure modes](07-failure-modes.md) --- Same jar, same machine, four runs each, median in the last column. From [`docs/output/05-what-helps.txt`](output/05-what-helps.txt): ``` variant | Started in (s), each run | median -----------------------------------+----------------------------+--------- no tracking | 6.871,6.456,7.473,6.992 | 6.9315 BufferingApplicationStartup | 6.448,6.856,6.416,6.528 | 6.488 FlightRecorder + recording | 6.496,6.739,6.343,6.475 | 6.4855 lazy-initialization | 5.075,5.331,5.217,5.266 | 5.2415 AOT cache (-XX:AOTCache) | 5.028,4.729,4.708,4.905 | 4.817 AOT cache + lazy | 3.649,3.488,3.354,3.384 | 3.436 ``` ## Measuring is free The two instrumented rows are *inside the noise band of the uninstrumented one* — the buffering median is actually lower than the baseline median, which tells you the difference is smaller than the run-to-run variance rather than that recording makes things faster. Whatever the reason people leave startup tracking off, cost is not one of it. It is reasonable to ship `BufferingApplicationStartup` in a staging profile permanently. ## Lazy initialisation: real, and it moves the cost ~6.93 s to ~5.24 s, about 24%. But the work is deferred, not removed: the first request that touches a deferred bean pays for it, and a readiness probe that returns `200` before those beans exist will send traffic to an application that is not ready. It also [shrinks the startup tree](06-the-classpath-scan-tax.md) — 324 steps against 400 — which makes it a poor thing to enable while you are still diagnosing. Use it in development. Think carefully in production. ## The JDK 25 AOT cache JDK 25 ships the Project Leyden AOT cache (JEP 483 class loading, JEP 515 method profiling). It is a two-step build: a training run records what the application loads, an assembly run turns that into a cache, and subsequent runs read it. ```bash java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -jar app.jar # train, then stop java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot -jar app.jar java -XX:AOTCache=app.aot -jar app.jar # every run after ``` ``` AOTCache creation is complete: /tmp/app.aot 118001664 bytes ``` 6.93 s to 4.82 s — **about 30%**, with no change to a line of application code. Combined with lazy initialisation, 3.44 s: half the original. Where does it come from? Running the same tracker under both: ``` run | parse ms | inst ms | webserver -----------------------+-----------+-----------+---------- plain | 986.66 | 4344.21 | 150.69 aotcache | 729.16 | 3155.96 | 100.96 ``` Every phase gets cheaper — parse by 26%, instantiation by 27%, web server creation by 33%. The AOT cache does not remove a phase; it removes class loading and linking, and class loading is distributed through all of them. That is a useful thing to know before you go looking for the one phase it "fixed". The costs are real: the cache is 118 MB for this small application, it is tied to the exact classpath that produced it, and a training run has to be part of your build. See the [Project Leyden AOT cache post](https://ankurm.com/project-leyden-aot-cache-java/) for the invalidation rules and the CI shape. ## What did not make the list - **Tuning the JVM's heap or GC.** The JFR recording holds 39 `jdk.GCPhasePause` events totalling **289.8 ms** across a 7.4 s startup — about 4%. Real, but it is not where the seconds are, and it is the cheapest thing on this list to get wrong. - **Switching web server.** `spring.boot.webserver.create` is 160 ms of 6,900. - **Removing the diagnostics endpoint.** It costs nothing at startup. Remove it because it exposes your wiring, not for speed. ## The honest order of operations 1. Turn on `BufferingApplicationStartup`, look at self time, and fix your own slow beans. `keystoreLoader` and `tariffCacheWarmer` here are 830 ms between them and both could be moved off the startup path. 2. Narrow the component scan if `classCount` is large. 3. Then, and only then, reach for the AOT cache — it is a build-pipeline change, and it is much easier to justify once you know it is not hiding a 500 ms `@PostConstruct`. --- ← prev [07 — Failure modes](07-failure-modes.md)