Files
spring-boot-demo/spring-boot-startup-time/docs/06-the-classpath-scan-tax.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

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

← prev 05 — JFR instead of a buffer · next → 07 — Failure modes