Files
spring-boot-demo/spring-boot-startup-time/docs/04-reading-the-step-tree.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.9 KiB

04 — Reading the step tree: self time versus total time

← prev 03 — The four phases · next → 05 — JFR instead of a buffer


/actuator/startup hands back a flat list of steps, each with a duration. The obvious move is to sort by duration. The obvious move is wrong, and it is wrong in a way that reliably sends people to optimise the wrong thing.

Steps nest. A step's duration includes every child step underneath it. Sorting by duration therefore ranks containers, not culprits. Here are the same 400 steps from the same run, sorted both ways (docs/output/02-startup-tree.txt):

-- slowest individual steps by TOTAL time (the containers) --
 total ms    self ms  step / tags
  5829.88     113.74  spring.context.refresh
  1803.62    1413.83  spring.beans.instantiate &entityManagerFactory
  1344.76      18.09  spring.context.beans.post-process
  1088.90      22.48  spring.context.beandef-registry.post-process
  1066.42    1014.51  spring.context.config-classes.parse
   514.24      10.41  spring.beans.instantiate reportTemplateRegistry
   503.84     503.84  spring.beans.instantiate tariffCacheWarmer
   479.45       8.62  spring.beans.instantiate orderController

spring.context.refresh is top of the list at 5.8 s and is worth nothing to you: it is the whole refresh. reportTemplateRegistry looks like the fifth worst bean in the application at 514 ms. It costs 10 ms. The other 504 ms belong to tariffCacheWarmer, which reportTemplateRegistry happens to depend on and therefore triggers. Same story for orderController at 479 ms total and 8.6 ms self: it is the first thing that asks for the repository.

Whichever bean happens to be constructed first in a dependency chain inherits the whole chain's cost in the total column. That is an artefact of construction order, not of cost.

Computing self time

Subtract the direct children. The steps carry id and parentId, so it is a one-pass aggregation:

Map<Long, Long> childNanos = new HashMap<>();
for (TimelineEvent event : timeline.getEvents()) {
    Long parent = event.getStartupStep().getParentId();
    if (parent != null) {
        childNanos.merge(parent, event.getDuration().toNanos(), Long::sum);
    }
}
long self = total - childNanos.getOrDefault(step.getId(), 0L);

Sorted by self time, the same run names four real costs and nothing else:

-- slowest individual steps by SELF time (the culprits) --
  self ms   total ms  step / tags
  1413.83    1803.62  spring.beans.instantiate &entityManagerFactory
  1014.51    1066.42  spring.context.config-classes.parse
   503.84     503.84  spring.beans.instantiate tariffCacheWarmer
   402.81     402.81  spring.boot.application.environment-prepared
   326.96     326.96  spring.beans.instantiate keystoreLoader
   268.41     312.88  spring.beans.instantiate dataSourceScriptDatabaseInitializer
   204.92     470.83  spring.beans.instantiate orderRepository

Hibernate's EntityManagerFactory, configuration parsing, and the two beans this repository put there on purpose. reportTemplateRegistry and orderController have correctly vanished.

StartupDiagnosticsEndpoint does this and serves it at /diag/startup. It is about 90 lines and it is the single highest-value thing in this repository. Delete it before shipping — it exposes bean names and wiring.

The & prefix

&entityManagerFactory is not a typo. The ampersand is Spring's FactoryBean dereference prefix, so that step is the factory being built, not the object it produces. When a slow bean name starts with &, the cost is in a FactoryBean implementation — here, LocalContainerEntityManagerFactoryBean bootstrapping Hibernate 7.4.5.


← prev 03 — The four phases · next → 05 — JFR instead of a buffer