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.
4.4 KiB
08 — What actually helps
← prev 07 — Failure modes
Same jar, same machine, four runs each, median in the last column. From
docs/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 — 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.
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 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.GCPhasePauseevents 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.createis 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
- Turn on
BufferingApplicationStartup, look at self time, and fix your own slow beans.keystoreLoaderandtariffCacheWarmerhere are 830 ms between them and both could be moved off the startup path. - Narrow the component scan if
classCountis large. - 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