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.
3.6 KiB
06 — The classpath-scan tax, measured
← prev 05 — JFR instead of a buffer · next → 07 — Failure modes
"Narrow your component scan" is standard advice with no number attached to it. Here is the
number, from docs/output/04-scan-tax.txt.
The experiment adds classes to the package @SpringBootApplication already scans and
changes nothing else. plain classes carry no annotation at all, which separates the cost
of scanning from the cost of creating beans.
variant | Started in (s) | parse ms | instMs | steps
---------------------------+---------------------+-----------+-----------+-------
baseline | 7.293,6.685,6.86 | 1055.69 | 4854.73 | 400
+5000 plain classes | 7.626,7.143,7.04 | 1617.38 | 4468.31 | 400
+5000 @Component | 11.02,11.647,11.481 | 3221.82 | 6097.98 | 5400
+5000 @Component, lazy | 9.688,9.374,9.609 | 3434.76 | 4982.61 | 324
Reproduce with RUNS=3 ./scripts/demo-scan-tax.sh (about ten minutes — each variant is a
full rebuild).
What the rows say
5,000 classes that are not beans cost ~560 ms. parse goes from 1056 ms to 1617 ms
while the step count stays at exactly 400 and instantiation is unchanged. That is roughly
0.11 ms per class inspected on this machine. The scanner opens every .class file
under the base package and reads its annotation metadata before it can decide the class is
uninteresting. Classes you never wrote a @Component on are still on the bill.
Making them beans roughly triples the tax. parse goes to 3.2 s, instantiation gains
1.2 s, and the timeline goes from 400 steps to 5,400. Startup goes from ~6.9 s to ~11.5 s.
Lazy initialisation does not touch the scan. With the same 5,000 components,
spring.main.lazy-initialization=true leaves parse at 3.4 s — statistically unchanged —
and buys back 1.1 s of instantiation, for ~9.6 s. Lazy init defers construction. Bean
definitions are still created, and every class is still scanned, because Spring cannot know
whether a class is a bean without looking at it.
That is the important asymmetry: scanning is paid at startup no matter what you do at runtime. The only fix is to scan less.
Note also that the lazy row records 324 steps against a baseline of 400. Lazy
initialisation makes the startup tree less informative at exactly the moment you are
trying to read it — the beans it defers never produce a spring.beans.instantiate step, so
the cost moves to the first request and out of your recording entirely.
What to do about it
- Set
scanBasePackagesexplicitly rather than relying on the package of the main class. An application whose main class sits incom.examplescanscom.example.**, which on a large monolith is everything. - Watch the
classCounttag onspring.context.config-classes.parse(visible under JFR, see chapter 05). This application reports 130. If yours reports several hundred, most of them arrived with a starter you are not using. spring-context-indexerstill ships (7.0.9 is on Central) and writes aMETA-INF/spring.componentsindex at compile time so the scanner does not walk the classpath. It is worth knowing about; Spring's own direction of travel is AOT instead.- The AOT cache is the bigger lever, and it is measured in chapter 08.
← prev 05 — JFR instead of a buffer · next → 07 — Failure modes