Adds spring-boot-startup-time/, the companion project for BLOG-618: a runnable Spring Boot 4.1.1 application on JDK 25 that installs BufferingApplicationStartup and FlightRecorderApplicationStartup behind a system property, and a /diag/startup endpoint that computes step self time -- the number /actuator/startup does not give you and the one that names the actual culprits. Captured under docs/output/: the step tree sorted both ways, the same startup as JFR events, a +5000-class experiment putting 0.11 ms per scanned class on the classpath scan tax, the silent truncation a 2048-step buffer performs, and JDK 25 AOT cache timings (6.93 s to 4.82 s). Post body and metadata live in post/. Moves the existing Actuator project into actuator-in-production/ so the repository holds one directory per article; the root README is now an index.
99 lines
4.4 KiB
Markdown
99 lines
4.4 KiB
Markdown
# 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)
|