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:
98
spring-boot-startup-time/docs/08-what-actually-helps.md
Normal file
98
spring-boot-startup-time/docs/08-what-actually-helps.md
Normal 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)
|
||||
Reference in New Issue
Block a user