Files
spring-boot-demo/spring-boot-startup-time/docs/08-what-actually-helps.md
Ankur Mhatre 958b401f0f Spring Boot startup time: bean-by-bean diagnosis, and one directory per post
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.
2026-09-05 00:17:37 +05:30

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.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