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:
62
spring-boot-startup-time/docs/01-the-number-boot-logs.md
Normal file
62
spring-boot-startup-time/docs/01-the-number-boot-logs.md
Normal 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)
|
||||
@@ -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)
|
||||
87
spring-boot-startup-time/docs/03-the-four-phases.md
Normal file
87
spring-boot-startup-time/docs/03-the-four-phases.md
Normal 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)
|
||||
86
spring-boot-startup-time/docs/04-reading-the-step-tree.md
Normal file
86
spring-boot-startup-time/docs/04-reading-the-step-tree.md
Normal 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)
|
||||
103
spring-boot-startup-time/docs/05-jfr-instead-of-a-buffer.md
Normal file
103
spring-boot-startup-time/docs/05-jfr-instead-of-a-buffer.md
Normal 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)
|
||||
67
spring-boot-startup-time/docs/06-the-classpath-scan-tax.md
Normal file
67
spring-boot-startup-time/docs/06-the-classpath-scan-tax.md
Normal 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)
|
||||
89
spring-boot-startup-time/docs/07-failure-modes.md
Normal file
89
spring-boot-startup-time/docs/07-failure-modes.md
Normal 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)
|
||||
98
spring-boot-startup-time/docs/08-what-actually-helps.md
Normal file
98
spring-boot-startup-time/docs/08-what-actually-helps.md
Normal 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)
|
||||
18
spring-boot-startup-time/docs/output/00-versions.txt
Normal file
18
spring-boot-startup-time/docs/output/00-versions.txt
Normal 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]
|
||||
56
spring-boot-startup-time/docs/output/01-api-corrections.txt
Normal file
56
spring-boot-startup-time/docs/output/01-api-corrections.txt
Normal 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.
|
||||
67
spring-boot-startup-time/docs/output/02-startup-tree.txt
Normal file
67
spring-boot-startup-time/docs/output/02-startup-tree.txt
Normal 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
|
||||
60
spring-boot-startup-time/docs/output/03-jfr.txt
Normal file
60
spring-boot-startup-time/docs/output/03-jfr.txt
Normal 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
|
||||
10
spring-boot-startup-time/docs/output/04-scan-tax.txt
Normal file
10
spring-boot-startup-time/docs/output/04-scan-tax.txt
Normal 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.
|
||||
32
spring-boot-startup-time/docs/output/05-what-helps.txt
Normal file
32
spring-boot-startup-time/docs/output/05-what-helps.txt
Normal 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.
|
||||
28
spring-boot-startup-time/docs/output/06-buffer-overflow.txt
Normal file
28
spring-boot-startup-time/docs/output/06-buffer-overflow.txt
Normal 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.
|
||||
Reference in New Issue
Block a user