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,62 @@
# 01 — The number Boot logs, and what it hides
next → [02 — Turning instrumentation on](02-turning-instrumentation-on.md)
---
Every Spring Boot application ends its startup with one line:
```
Started StartupDiagnosisApplication in 6.6 seconds (process running for 7.4)
```
Two numbers. The first is measured from the `SpringApplication.run()` call to the
`ApplicationReadyEvent`. The second is `ManagementFactory.getRuntimeMXBean().getUptime()`,
so the gap between them is JVM bootstrap: opening the jar, verifying classes, starting the
JIT — work that happens before your code runs at all.
Neither number tells you where the 6.6 seconds went, and the usual next move — reading the
log timestamps — is worse than it looks. The log only shows you components that chose to
log. This application spends about half a second inside `tariffCacheWarmer` and logs
nothing while doing it. On the timeline it is a silent gap between two Hibernate lines,
and you would reasonably conclude that Hibernate was slow.
## What is actually available
Spring Framework has carried an `ApplicationStartup` SPI since 5.3. It is a tracing
interface with one method that matters:
```java
public interface ApplicationStartup {
StartupStep start(String name);
}
```
Framework and Boot call it at about two dozen named points — `spring.beans.instantiate`,
`spring.context.config-classes.parse`, `spring.data.repository.proxy`, and so on — tagging
each step with the bean name or class count involved. Steps nest, so what you get is a
tree, not a list.
The default implementation, `DefaultApplicationStartup`, does nothing. That is the whole
reason startup profiling feels unavailable: the instrumentation is already in your
application and is switched off.
Two implementations record it:
| Implementation | Where it lives | Output |
|---|---|---|
| `BufferingApplicationStartup` | `spring-boot` | in-memory buffer, read over HTTP |
| `FlightRecorderApplicationStartup` | `spring-core` | JFR events in a `.jfr` file |
There is no third option and no third-party agent needed.
## What this repository measures
The application here is deliberately ordinary: web, JPA over H2, three repositories, and
four beans that do real work on the way up. It starts in about 6.6 seconds on the machine
that produced [`docs/output/`](output). Every figure in the article and in these chapters
came from a file in that directory.
---
next → [02 — Turning instrumentation on](02-turning-instrumentation-on.md)

View File

@@ -0,0 +1,82 @@
# 02 — Turning instrumentation on
← prev [01 — The number Boot logs](01-the-number-boot-logs.md) · next → [03 — The four phases](03-the-four-phases.md)
---
## There is no property for this
The single most common wasted hour: looking for `spring.application.startup=buffering` in
`application.yaml`. It does not exist. `ApplicationStartup` has to be set on the
`SpringApplication` **before** `run()`, because the steps you care about are recorded
before any configuration file has been read.
```java
public static void main(String[] args) {
SpringApplication app = new SpringApplication(StartupDiagnosisApplication.class);
int capacity = Integer.getInteger("startup.buffer", 16384);
switch (System.getProperty("startup.tracking", "buffering")) {
case "buffering" -> app.setApplicationStartup(new BufferingApplicationStartup(capacity));
case "jfr" -> app.setApplicationStartup(new FlightRecorderApplicationStartup());
default -> { }
}
app.run(args);
}
```
That means changing which tracker you use is a **redeploy**, not a config change — worth
knowing before an incident, not during one. See
[`StartupDiagnosisApplication.java`](../src/main/java/com/ankurm/startup/StartupDiagnosisApplication.java).
## Exposing the endpoint
`startup` is not web-exposed by default:
```yaml
management:
endpoints:
web:
exposure:
include: health,info,startup,beans,conditions
```
Exposing it without installing a tracker gives you an endpoint that returns an empty
timeline rather than an error, which is a confusing way to lose twenty minutes.
## GET peeks, POST drains
This is the trap that costs people their only recording. From
[`docs/output/02-startup-tree.txt`](output/02-startup-tree.txt):
```
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
```
`POST` calls `drainBufferedTimeline()`, which empties the buffer so the memory can be
reclaimed. Every guide shows the `POST`. If you pipe it to a file and the file is wrong, the
data is gone — the application has to be restarted to record it again. Use `GET` while you
are still working out what you want.
## You do not need a `@Bean` for it
Boot registers the instance as a singleton named `applicationStartup` before the context
refreshes, so it can simply be injected. Declaring your own `@Bean` produces:
```
Parameter 0 of constructor in ...StartupDiagnosticsEndpoint required a single bean,
but 2 were found:
- bufferingApplicationStartup: defined by method 'bufferingApplicationStartup' ...
- applicationStartup: a programmatically registered singleton
```
Inject the `ApplicationStartup` interface and narrow with `instanceof`, not the concrete
`BufferingApplicationStartup` — otherwise the application refuses to start whenever
somebody runs it without tracking. Full transcript in
[`docs/output/01-api-corrections.txt`](output/01-api-corrections.txt).
---
← prev [01 — The number Boot logs](01-the-number-boot-logs.md) · next → [03 — The four phases](03-the-four-phases.md)

View File

@@ -0,0 +1,87 @@
# 03 — The four phases hiding inside one number
← prev [02 — Turning instrumentation on](02-turning-instrumentation-on.md) · next → [04 — Reading the step tree](04-reading-the-step-tree.md)
---
Grouping every recorded step by name and summing **self time** (see
[chapter 04](04-reading-the-step-tree.md) for why self and not total) gives the shape of a
startup. From [`docs/output/02-startup-tree.txt`](output/02-startup-tree.txt):
```
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
```
Four phases account for essentially all of it, and they respond to entirely different
fixes.
## 1. Bean instantiation — `spring.beans.instantiate`
361 steps, 4.25 s. This is constructors, `@PostConstruct`, `FactoryBean.getObject()` and
proxy creation. It is where your own code lives, and it is the only phase you can fix by
changing application code.
Note what is *not* a separate step: `@PostConstruct` has no step of its own, so a cache
warm shows up inside the owning bean's `spring.beans.instantiate`. `tariffCacheWarmer` in
this repository spends all of its 504 ms in `@PostConstruct` and none in its constructor,
and the timeline cannot tell you which.
## 2. Configuration class parsing — `spring.context.config-classes.parse`
One step, 1.01 s, and under JFR it carries a tag naming the cost driver:
```
PT1.176668418S spring.context.config-classes.parse classCount=130,
```
130 configuration classes. This step is component scanning plus `@Conditional` evaluation
across every auto-configuration your classpath drags in. It is proportional to *classes
inspected*, not to beans created — see [chapter 06](06-the-classpath-scan-tax.md), where
adding 5,000 classes that are not beans at all still adds half a second here.
## 3. Environment preparation — `spring.boot.application.environment-prepared`
403 ms before any bean exists. Property sources, profile resolution, config data imports.
Cheap to ignore and impossible to tune from application code, but it explains why "the app
does nothing for the first half second".
## 4. Everything else
`spring.boot.webserver.create` (160 ms), the Spring Data repository steps (~150 ms across
five step names), `spring.beans.smart-initialize`. Individually small; collectively a
second. Spring Data's contribution scales with the number of repository *interfaces* and
derived query methods, since each one is parsed and proxied.
## The step names, in the order they nest
```
spring.boot.application.starting
spring.boot.application.environment-prepared
spring.boot.application.context-prepared
spring.context.refresh
├── spring.context.beandef-registry.post-process
│ └── spring.context.config-classes.parse ← scanning + conditions
├── spring.context.bean-factory.post-process
├── spring.context.beans.post-process
│ └── spring.beans.instantiate (×N, nested by dependency)
└── spring.boot.webserver.create
spring.boot.application.started
spring.boot.application.ready
```
A step is recorded **when it ends**, which is why the outermost ones appear last in the
timeline — and why they are the first casualties of a full buffer
([chapter 07](07-failure-modes.md)).
---
← prev [02 — Turning instrumentation on](02-turning-instrumentation-on.md) · next → [04 — Reading the step tree](04-reading-the-step-tree.md)

View File

@@ -0,0 +1,86 @@
# 04 — Reading the step tree: self time versus total time
← prev [03 — The four phases](03-the-four-phases.md) · next → [05 — JFR instead of a buffer](05-jfr-instead-of-a-buffer.md)
---
`/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`](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:
```java
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`](../src/main/java/com/ankurm/startup/web/StartupDiagnosticsEndpoint.java)
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](03-the-four-phases.md) · next → [05 — JFR instead of a buffer](05-jfr-instead-of-a-buffer.md)

View File

@@ -0,0 +1,103 @@
# 05 — JFR instead of a buffer
← prev [04 — Reading the step tree](04-reading-the-step-tree.md) · next → [06 — The classpath-scan tax](06-the-classpath-scan-tax.md)
---
`FlightRecorderApplicationStartup` lives in `spring-core`, needs no dependency, and emits
each `StartupStep` as a JFR event. Swap the tracker and start a recording:
```bash
java -XX:StartFlightRecording=filename=startup.jfr,settings=profile,dumponexit=true \
-Dstartup.tracking=jfr -jar app.jar
```
Everything below is from [`docs/output/03-jfr.txt`](output/03-jfr.txt).
## The event type
```
@Name("org.springframework.core.metrics.jfr.FlightRecorderStartupEvent")
@Category("Spring Application")
@Label("Startup Step")
@Description("Spring Application Startup")
class FlightRecorderStartupEvent extends jdk.jfr.Event { ... }
```
In JDK Mission Control it appears under the **Spring Application** category. On the command
line, the name matters more than you would expect:
```
--events StartupEvent -> 0 events
--events 'org.springframework.core.metrics.jfr.FlightRecorderStartupEvent' -> 398 events
```
`jfr print --events` matches the `@Name`, which is the fully qualified class name. The short
form silently returns nothing — no error, no warning, just an empty result that reads like
"Spring did not record anything".
## Reading it without Mission Control
```bash
jfr summary startup.jfr
jfr print --events 'org.springframework.core.metrics.jfr.FlightRecorderStartupEvent' \
--json startup.jfr
```
The eight slowest, sorted by the recording's own `duration` field:
```
duration name / tags
PT6.544849841S spring.context.refresh
PT2.263845435S spring.beans.instantiate beanName=&entityManagerFactory,...
PT1.500763631S spring.context.beans.post-process
PT1.202591188S spring.context.beandef-registry.post-process postProcessor=...ConfigurationClassPostProcessor@76b224cd
PT1.176668418S spring.context.config-classes.parse classCount=130
PT0.538651275S spring.beans.instantiate beanName=reportTemplateRegistry
PT0.535484510S spring.beans.instantiate beanName=tariffCacheWarmer
PT0.515340537S spring.boot.webserver.create factory=...TomcatServletWebServerFactory
```
(That run started in 7.441 s; the buffered run in [chapter 04](04-reading-the-step-tree.md)
started in 6.6 s. Same jar, different runs — see [chapter 07](07-failure-modes.md) on why
a single measurement is not a result.)
Note that this is the *total time* list from
[chapter 04](04-reading-the-step-tree.md), with `spring.context.refresh` on top and
`reportTemplateRegistry` above `tariffCacheWarmer`. JFR gives you durations and parent ids;
it does not compute self time either. The same subtraction applies.
## What JFR buys you that the buffer does not
Correlation. The recording holds the JVM's own view of the same seconds:
```
jdk.ExecutionSample 345
jdk.GCPhasePauseLevel1 132
jdk.GCPhasePause 39 (289.8 ms of pause in total)
jdk.Compilation 16
jdk.ClassLoaderStatistics 10
```
If a bean's constructor is slow because a young collection landed in the middle of it, the
buffer shows you a slow bean and JFR shows you why. That is the case for using it.
## What it costs you
- The recording is written to a file on the machine, not served over HTTP. In a container
that means a volume or a copy out.
- The `tags` are serialised into one flat `String` attribute, because JFR events only carry
base types. Parsing `beanName=x,beanType=y,` is on you.
- `settings=profile` records a great deal more than startup steps. For a 7-second startup
that is fine; left on in production it is not.
## Buffer or JFR?
Use the buffer when the question is "which bean", and you can reach the application over
HTTP. Use JFR when the question is "why is that bean slow", when startup fails before the
web server is up (the buffer is unreachable then; the JFR file is not), or when you need
the same recording to answer a GC question.
---
← prev [04 — Reading the step tree](04-reading-the-step-tree.md) · next → [06 — The classpath-scan tax](06-the-classpath-scan-tax.md)

View File

@@ -0,0 +1,67 @@
# 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)

View File

@@ -0,0 +1,89 @@
# 07 — Failure modes
← prev [06 — The classpath-scan tax](06-the-classpath-scan-tax.md) · next → [08 — What actually helps](08-what-actually-helps.md)
---
## The buffer truncates silently
`BufferingApplicationStartup` takes a capacity in its only constructor. Every guide picks
2048. Nothing tells you what happens when the application produces more steps than that —
so here it is, with 5,000 extra `@Component` classes on the classpath
([`docs/output/06-buffer-overflow.txt`](output/06-buffer-overflow.txt)):
```
--- capacity 2048 ---
started, recorded steps: 2048
--- capacity 16384 ---
started, recorded steps: 5400
```
The endpoint returns `200` with a well-formed answer. `grep -ic buffer app.log` returns
**0**. There is no warning and no exception.
It is worse than losing a suffix. Steps are recorded **when they end**, so the buffer keeps
the *first* ones to finish — the innermost leaves — and drops the enclosing ones:
```
first 3: ['spring.boot.application.starting',
'spring.boot.application.environment-prepared',
'spring.boot.application.context-prepared']
last 3 : ['spring.beans.instantiate', ...]
last tags: [{'key': 'beanName', 'value': 'bulk2718'}]
```
`spring.context.refresh`, `spring.boot.application.started` and
`spring.boot.application.ready` are absent from the truncated timeline. Any self-time
calculation is now missing its outer frame, and any "the whole refresh took N" figure is
simply not in the data.
The capacity is the number of **steps**, not beans, and this application records slightly
more than one step per bean. 16384 costs a few megabytes that are freed on the first drain.
Pick the large number.
`aFullBufferTruncatesSilentlyAndLosesTheOuterSteps` in
[`StartupTimelineContractTests`](../src/test/java/com/ankurm/startup/StartupTimelineContractTests.java)
pins this behaviour.
## `POST` destroys the recording
Covered in [chapter 02](02-turning-instrumentation-on.md), repeated here because it is the
most expensive one: `POST /actuator/startup` drains. The second call returns an empty
timeline, and so does every subsequent `GET`. If you are following a guide that pipes the
`POST` into `jq` and the `jq` expression is wrong, restart the application — the data is
gone.
## Injecting the concrete type
```java
public StartupDiagnosticsEndpoint(BufferingApplicationStartup startup) { ... }
```
compiles, works in every test, and stops the application from starting the first time
somebody runs it without `-Dstartup.tracking=buffering`, because the registered singleton
is then a `DefaultApplicationStartup`. Inject the interface, narrow with `instanceof`, and
answer honestly when tracking is off.
## Killing the app by its main class
Not a Spring problem, but it cost a run while producing this repository. `pkill -f
StartupDiagnosisApplication` also matches the shell that is running the script that contains
that string, and kills it. [`scripts/stop.sh`](../scripts/stop.sh) matches on the executable
being `java` **and** the jar name:
```bash
ps -eo pid=,comm=,args= | awk '$2 == "java" && /startup-diagnosis-1\.0\.0\.jar/ { print $1 }'
```
## Trusting a single run
Startup timings on a shared or containerised machine move by several hundred milliseconds
between runs of the same jar — see the per-run columns in
[`docs/output/05-what-helps.txt`](output/05-what-helps.txt), where the same configuration
produced 6.456 s and 7.473 s. Take a median of four or more, and treat any difference under
about 10% as noise. Every number in this repository that matters is a ratio between two
such medians, not a single measurement.
---
← prev [06 — The classpath-scan tax](06-the-classpath-scan-tax.md) · next → [08 — What actually helps](08-what-actually-helps.md)

View File

@@ -0,0 +1,98 @@
# 08 — What actually helps
← prev [07 — Failure modes](07-failure-modes.md)
---
Same jar, same machine, four runs each, median in the last column. From
[`docs/output/05-what-helps.txt`](output/05-what-helps.txt):
```
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
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
```
## Measuring is free
The two instrumented rows are *inside the noise band of the uninstrumented one* — the
buffering median is actually lower than the baseline median, which tells you the difference
is smaller than the run-to-run variance rather than that recording makes things faster.
Whatever the reason people leave startup tracking off, cost is not one of it. It is
reasonable to ship `BufferingApplicationStartup` in a staging profile permanently.
## Lazy initialisation: real, and it moves the cost
~6.93 s to ~5.24 s, about 24%. But the work is deferred, not removed: the first request that
touches a deferred bean pays for it, and a readiness probe that returns `200` before those
beans exist will send traffic to an application that is not ready. It also
[shrinks the startup tree](06-the-classpath-scan-tax.md) — 324 steps against 400 — which
makes it a poor thing to enable while you are still diagnosing.
Use it in development. Think carefully in production.
## The JDK 25 AOT cache
JDK 25 ships the Project Leyden AOT cache (JEP 483 class loading, JEP 515 method profiling).
It is a two-step build: a training run records what the application loads, an assembly run
turns that into a cache, and subsequent runs read it.
```bash
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -jar app.jar # train, then stop
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot -jar app.jar
java -XX:AOTCache=app.aot -jar app.jar # every run after
```
```
AOTCache creation is complete: /tmp/app.aot 118001664 bytes
```
6.93 s to 4.82 s — **about 30%**, with no change to a line of application code. Combined
with lazy initialisation, 3.44 s: half the original.
Where does it come from? Running the same tracker under both:
```
run | parse ms | inst ms | webserver
-----------------------+-----------+-----------+----------
plain | 986.66 | 4344.21 | 150.69
aotcache | 729.16 | 3155.96 | 100.96
```
Every phase gets cheaper — parse by 26%, instantiation by 27%, web server creation by 33%.
The AOT cache does not remove a phase; it removes class loading and linking, and class
loading is distributed through all of them. That is a useful thing to know before you go
looking for the one phase it "fixed".
The costs are real: the cache is 118 MB for this small application, it is tied to the exact
classpath that produced it, and a training run has to be part of your build. See the
[Project Leyden AOT cache post](https://ankurm.com/project-leyden-aot-cache-java/) for the
invalidation rules and the CI shape.
## What did not make the list
- **Tuning the JVM's heap or GC.** The JFR recording holds 39 `jdk.GCPhasePause` events
totalling **289.8 ms** across a 7.4 s startup — about 4%. Real, but it is not where the
seconds are, and it is the cheapest thing on this list to get wrong.
- **Switching web server.** `spring.boot.webserver.create` is 160 ms of 6,900.
- **Removing the diagnostics endpoint.** It costs nothing at startup. Remove it because it
exposes your wiring, not for speed.
## The honest order of operations
1. Turn on `BufferingApplicationStartup`, look at self time, and fix your own slow beans.
`keystoreLoader` and `tariffCacheWarmer` here are 830 ms between them and both could be
moved off the startup path.
2. Narrow the component scan if `classCount` is large.
3. Then, and only then, reach for the AOT cache — it is a build-pipeline change, and it is
much easier to justify once you know it is not hiding a 500 ms `@PostConstruct`.
---
← prev [07 — Failure modes](07-failure-modes.md)

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.