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:
+97
@@ -0,0 +1,97 @@
|
||||
package com.ankurm.startup;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup;
|
||||
import org.springframework.boot.context.metrics.buffering.StartupTimeline;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.core.metrics.ApplicationStartup;
|
||||
|
||||
/**
|
||||
* Pins the behaviour the post relies on, rather than the happy path.
|
||||
* See docs/07-failure-modes.md.
|
||||
*/
|
||||
class StartupTimelineContractTests {
|
||||
|
||||
private ConfigurableApplicationContext boot(int capacity) {
|
||||
return new SpringApplicationBuilder(StartupDiagnosisApplication.class)
|
||||
.web(WebApplicationType.NONE)
|
||||
.applicationStartup(new BufferingApplicationStartup(capacity))
|
||||
.properties("spring.main.banner-mode=off")
|
||||
.run();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theStartupInstanceIsRegisteredAsASingletonSoNoBeanMethodIsNeeded() {
|
||||
try (ConfigurableApplicationContext ctx = boot(16384)) {
|
||||
ApplicationStartup fromContext = ctx.getApplicationStartup();
|
||||
ApplicationStartup fromBeanFactory = ctx.getBean(ApplicationStartup.class);
|
||||
assertThat(fromBeanFactory).isSameAs(fromContext);
|
||||
assertThat(ctx.getBeanNamesForType(ApplicationStartup.class))
|
||||
.containsExactly("applicationStartup");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void drainEmptiesTheBufferAndGetDoesNot() {
|
||||
try (ConfigurableApplicationContext ctx = boot(16384)) {
|
||||
BufferingApplicationStartup startup =
|
||||
(BufferingApplicationStartup) ctx.getApplicationStartup();
|
||||
|
||||
assertThat(startup.getBufferedTimeline().getEvents()).isNotEmpty();
|
||||
// A second peek still sees everything: GET /actuator/startup is safe to repeat.
|
||||
assertThat(startup.getBufferedTimeline().getEvents()).isNotEmpty();
|
||||
|
||||
StartupTimeline drained = startup.drainBufferedTimeline();
|
||||
assertThat(drained.getEvents()).isNotEmpty();
|
||||
// POST /actuator/startup is not. Everything after the first call is empty.
|
||||
assertThat(startup.drainBufferedTimeline().getEvents()).isEmpty();
|
||||
assertThat(startup.getBufferedTimeline().getEvents()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFullBufferTruncatesSilentlyAndLosesTheOuterSteps() {
|
||||
try (ConfigurableApplicationContext ctx = boot(64)) {
|
||||
BufferingApplicationStartup startup =
|
||||
(BufferingApplicationStartup) ctx.getApplicationStartup();
|
||||
var events = startup.getBufferedTimeline().getEvents();
|
||||
|
||||
assertThat(events).hasSize(64);
|
||||
// The steps that bracket the whole refresh end last, so they are the ones lost.
|
||||
assertThat(events)
|
||||
.extracting(e -> e.getStartupStep().getName())
|
||||
.doesNotContain("spring.context.refresh");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void totalTimeDoubleCountsAndSelfTimeDoesNot() {
|
||||
try (ConfigurableApplicationContext ctx = boot(16384)) {
|
||||
BufferingApplicationStartup startup =
|
||||
(BufferingApplicationStartup) ctx.getApplicationStartup();
|
||||
var events = startup.getBufferedTimeline().getEvents();
|
||||
|
||||
Map<Long, Long> childNanos = new java.util.HashMap<>();
|
||||
for (StartupTimeline.TimelineEvent e : events) {
|
||||
Long parent = e.getStartupStep().getParentId();
|
||||
if (parent != null) {
|
||||
childNanos.merge(parent, e.getDuration().toNanos(), Long::sum);
|
||||
}
|
||||
}
|
||||
long refreshTotal = events.stream()
|
||||
.filter(e -> e.getStartupStep().getName().equals("spring.context.refresh"))
|
||||
.mapToLong(e -> e.getDuration().toNanos()).max().orElseThrow();
|
||||
long refreshId = events.stream()
|
||||
.filter(e -> e.getStartupStep().getName().equals("spring.context.refresh"))
|
||||
.mapToLong(e -> e.getStartupStep().getId()).findFirst().orElseThrow();
|
||||
|
||||
// The outermost refresh step contains almost all of its own duration in children.
|
||||
assertThat(childNanos.get(refreshId)).isGreaterThan(refreshTotal / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user