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.
This commit is contained in:
2026-09-04 23:58:38 +05:30
parent 4b6cefa60a
commit 958b401f0f
112 changed files with 2744 additions and 154 deletions

View File

@@ -0,0 +1,98 @@
# 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)