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.
68 lines
3.6 KiB
Markdown
68 lines
3.6 KiB
Markdown
# 06 — The classpath-scan tax, measured
|
|
|
|
← prev [05 — JFR instead of a buffer](05-jfr-instead-of-a-buffer.md) · next → [07 — Failure modes](07-failure-modes.md)
|
|
|
|
---
|
|
|
|
"Narrow your component scan" is standard advice with no number attached to it. Here is the
|
|
number, from [`docs/output/04-scan-tax.txt`](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 `scanBasePackages` explicitly rather than relying on the package of the main class.
|
|
An application whose main class sits in `com.example` scans `com.example.**`, which on a
|
|
large monolith is everything.
|
|
- Watch the `classCount` tag on `spring.context.config-classes.parse` (visible under JFR,
|
|
see [chapter 05](05-jfr-instead-of-a-buffer.md)). This application reports 130. If yours
|
|
reports several hundred, most of them arrived with a starter you are not using.
|
|
- `spring-context-indexer` still ships (7.0.9 is on Central) and writes a
|
|
`META-INF/spring.components` index 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](08-what-actually-helps.md).
|
|
|
|
---
|
|
|
|
← prev [05 — JFR instead of a buffer](05-jfr-instead-of-a-buffer.md) · next → [07 — Failure modes](07-failure-modes.md)
|