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:
2026-09-04 23:58:38 +05:30
parent 4b6cefa60a
commit 958b401f0f
112 changed files with 2744 additions and 154 deletions

View File

@@ -0,0 +1,18 @@
=== java -version ===
openjdk version "25.0.4.1" 2026-08-18 LTS
OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS, mixed mode, sharing)
=== mvn -version ===
Apache Maven 3.9.11 (3e54c93a704957b63ee3494413a2b544fd3d825b)
=== mvn dependency:list (selected) ===
com.h2database:h2:jar:2.4.240:runtime -- module com.h2database [auto]
io.micrometer:micrometer-core:jar:1.17.1:compile -- module micrometer.core [auto]
org.apache.tomcat.embed:tomcat-embed-core:jar:11.0.24:compile -- module org.apache.tomcat.embed.core
org.hibernate.orm:hibernate-core:jar:7.4.5.Final:compile -- module org.hibernate.orm.core [auto]
org.springframework.boot:spring-boot:jar:4.1.1:compile -- module spring.boot [auto]
org.springframework.data:spring-data-jpa:jar:4.1.1:compile -- module spring.data.jpa [auto]
org.springframework:spring-beans:jar:7.0.9:compile -- module spring.beans [auto]
org.springframework:spring-context:jar:7.0.9:compile -- module spring.context [auto]
org.springframework:spring-core:jar:7.0.9:compile -- module spring.core [auto]

View File

@@ -0,0 +1,56 @@
=== Where getApplicationStartup() actually lives (Spring Framework 7.0.9) ===
Injecting ApplicationContext and calling getApplicationStartup() does not compile:
[ERROR] .../config/StartupBeans.java:[22,45] cannot find symbol
symbol: method getApplicationStartup()
location: variable context of type org.springframework.context.ApplicationContext
javap says the accessor is declared one interface down, and also on the bean factory:
$ javap -cp . org.springframework.context.ConfigurableApplicationContext | grep ApplicationStartup
public abstract void setApplicationStartup(org.springframework.core.metrics.ApplicationStartup);
public abstract org.springframework.core.metrics.ApplicationStartup getApplicationStartup();
$ javap -cp . org.springframework.beans.factory.config.ConfigurableBeanFactory | grep ApplicationStartup
public abstract void setApplicationStartup(org.springframework.core.metrics.ApplicationStartup);
public abstract org.springframework.core.metrics.ApplicationStartup getApplicationStartup();
=== The buffering API surface (spring-boot 4.1.1) ===
$ javap -cp . org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup
public class BufferingApplicationStartup implements org.springframework.core.metrics.ApplicationStartup {
public BufferingApplicationStartup(int);
public void startRecording();
public void addFilter(java.util.function.Predicate<org.springframework.core.metrics.StartupStep>);
public org.springframework.core.metrics.StartupStep start(java.lang.String);
public StartupTimeline getBufferedTimeline();
public StartupTimeline drainBufferedTimeline();
}
$ javap -cp . org.springframework.boot.context.metrics.buffering.StartupTimeline$TimelineEvent
public class StartupTimeline$TimelineEvent {
public java.time.Instant getStartTime();
public java.time.Instant getEndTime();
public java.time.Duration getDuration();
public org.springframework.core.metrics.StartupStep getStartupStep();
}
Note: there is no setter for the buffer size and no Spring property that installs this.
The only constructor takes the capacity, and it must be handed to SpringApplication
before run().
=== You do not need a @Bean method for it ===
Declaring one produces a startup failure, because Boot has already registered the
instance as a singleton:
Parameter 0 of constructor in com.ankurm.startup.web.StartupDiagnosticsEndpoint
required a single bean, but 2 were found:
- bufferingApplicationStartup: defined by method 'bufferingApplicationStartup'
in class path resource [com/ankurm/startup/config/StartupBeans.class]
- applicationStartup: a programmatically registered singleton
So the correct move is to inject ApplicationStartup directly and narrow with instanceof.
Injecting BufferingApplicationStartup by its concrete type compiles and works -- until
somebody runs without tracking, at which point the application will not start at all.

View File

@@ -0,0 +1,67 @@
=== Boot's own startup line ===
Started StartupDiagnosisApplication in 6.6 seconds (process running for 7.4)
=== /diag/startup ===
recorded steps: 400
-- self time by step name (self = duration minus direct children) --
self ms count step
4253.68 361 spring.beans.instantiate
1014.51 1 spring.context.config-classes.parse
402.81 1 spring.boot.application.environment-prepared
160.39 1 spring.boot.webserver.create
153.07 1 spring.boot.application.started
113.74 1 spring.context.refresh
95.72 1 spring.data.repository.proxy
51.50 6 spring.beans.smart-initialize
43.85 1 spring.data.repository.scanning
35.02 1 spring.boot.application.starting
31.10 9 spring.context.bean-factory.post-process
22.48 1 spring.context.beandef-registry.post-process
18.09 1 spring.context.beans.post-process
13.78 1 spring.boot.application.context-loaded
12.92 1 spring.data.repository.composition
5.15 5 spring.data.repository.postprocessor
4.70 1 spring.data.repository.init
0.89 1 spring.data.repository.target
0.68 1 spring.context.config-classes.enhance
0.48 1 spring.boot.application.ready
0.42 1 spring.data.repository.postprocessors
0.26 1 spring.boot.application.context-prepared
0.07 1 spring.data.repository.metadata
-- 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
160.39 460.45 spring.boot.webserver.create
153.07 153.07 spring.boot.application.started
113.74 5829.88 spring.context.refresh
95.72 101.29 spring.data.repository.proxy
64.25 64.25 spring.beans.instantiate jpaMappingContext
-- 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
470.83 204.92 spring.beans.instantiate orderRepository
460.45 160.39 spring.boot.webserver.create
402.81 402.81 spring.boot.application.environment-prepared
326.96 326.96 spring.beans.instantiate keystoreLoader
=== actuator's own endpoint: GET peeks, POST drains ===
GET /actuator/startup -> events in response: 400
POST /actuator/startup -> events in response: 400
POST /actuator/startup -> events in response: 0
GET /actuator/startup -> events in response: 0

View File

@@ -0,0 +1,60 @@
Started StartupDiagnosisApplication in 7.441 seconds (process running for 8.49)
=== jfr summary (Spring rows only) ===
Version: 2.1
Chunks: 1
=========================================================================================
org.springframework.core.metrics.jfr.FlightRecorderStartupEvent 398 33261
=== the event type, in full ===
@Name("org.springframework.core.metrics.jfr.FlightRecorderStartupEvent")
@Category("Spring Application")
@Label("Startup Step")
@Description("Spring Application Startup")
class FlightRecorderStartupEvent extends jdk.jfr.Event {
@Label("Start Time")
@Timestamp("TICKS")
long startTime;
@Label("Duration")
@Timespan("TICKS")
long duration;
@Label("Event Thread")
@Description("Thread in which event was committed in")
Thread eventThread;
@Label("Stack Trace")
@Description("Stack Trace starting from the method the event was committed in")
StackTrace stackTrace;
long eventId;
=== the selector matters: the event is named by its FQCN, not 'StartupEvent' ===
--events StartupEvent -> 0 events
--events 'org.springframework.core.*' -> 398 events
=== 8 slowest startup steps, straight out of the recording ===
398 StartupEvent records in the recording
duration name / tags
PT6.544849841S spring.context.refresh
PT2.263845435S spring.beans.instantiate beanName=&entityManagerFactory,beanType=interface org.springframework.context.weaving.LoadTimeWeaverAware,
PT1.500763631S spring.context.beans.post-process
PT1.202591188S spring.context.beandef-registry.post-process postProcessor=org.springframework.context.annotation.ConfigurationClassPostProcessor@76b224cd,
PT1.176668418S spring.context.config-classes.parse classCount=130,
PT0.538651275S spring.beans.instantiate beanName=reportTemplateRegistry,
PT0.53548451S spring.beans.instantiate beanName=tariffCacheWarmer,beanType=class com.ankurm.startup.slow.TariffCacheWarmer,
PT0.515340537S spring.boot.webserver.create factory=class org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory,
=== what JFR gives you that the buffer does not: JVM context in the same file ===
jdk.ExecutionSample 345 3787
jdk.GCPhasePauseLevel1 132 5281
jdk.GCPhasePauseLevel2 50 1744
jdk.GCPhasePause 39 937
jdk.Compilation 16 452
jdk.ClassLoaderStatistics 10 260
total GC pause time during this startup: 39 events, 289.8 ms

View File

@@ -0,0 +1,10 @@
runs per variant: 3 (first run of each also queried for phase self times)
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
bulk package removed; jar rebuilt at baseline.

View File

@@ -0,0 +1,32 @@
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
=== JDK 25 AOT cache (Project Leyden) ===
openjdk version "25.0.4.1" 2026-08-18 LTS
-- training run (-XX:AOTMode=record) --
-rw-r--r-- 1 eloquent-blissful-maxwell eloquent-blissful-maxwell 119484416 Sep 4 23:44 /tmp/app.aotconf
-- assembly run (-XX:AOTMode=create) --
Reading AOTConfiguration /tmp/app.aotconf and writing AOTCache /tmp/app.aot
AOTCache creation is complete: /tmp/app.aot 118001664 bytes
-rw-r--r-- 1 eloquent-blissful-maxwell eloquent-blissful-maxwell 118001664 Sep 4 23:44 /tmp/app.aot
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
=== where the AOT cache takes the time from (same tracker, same jar) ===
run | parse ms | inst ms | webserver
-----------------------+-----------+-----------+----------
plain | 986.66 | 4344.21 | 150.69
aotcache | 729.16 | 3155.96 | 100.96
Notes:
* Timings are from one machine (Temurin 25.0.4.1+1, Linux x86_64, container) and are
indicative. The ratios between rows are the point, not the absolute numbers.
* "no tracking" and the two instrumented rows overlap. Recording startup steps is not
measurably slower than not recording them on this application.
* The AOT cache was trained on this exact jar with -XX:AOTMode=record and assembled with
-XX:AOTMode=create. It is 118 MB and is invalidated by a classpath change.

View File

@@ -0,0 +1,28 @@
--- capacity 2048 ---
started, recorded steps: 2048
--- capacity 16384 ---
started, recorded steps: 5400
=== which steps survive truncation (capacity 2048, 5400 available) ===
$ curl -s localhost:8080/actuator/startup | python3 -c '...'
events: 2048
first 3: ['spring.boot.application.starting',
'spring.boot.application.environment-prepared',
'spring.boot.application.context-prepared']
last 3 : ['spring.beans.instantiate', 'spring.beans.instantiate', 'spring.beans.instantiate']
last tags: [{'key': 'beanName', 'value': 'bulk2718'}]
names: [('spring.beans.instantiate', 2029),
('spring.context.bean-factory.post-process', 9),
('spring.boot.application.starting', 1),
('spring.boot.application.environment-prepared', 1)]
The buffer keeps the FIRST 2048 steps and drops everything after. The timeline therefore
ends mid-instantiation, and 'spring.context.refresh', 'spring.boot.application.started'
and 'spring.boot.application.ready' -- the steps that bracket everything else -- are
absent, because a step is only recorded when it ENDS.
$ grep -ic buffer app.log
0
No warning. No exception. The endpoint returns 200 with a well-formed, truncated answer.