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,24 @@
package com.ankurm.startup;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup;
import org.springframework.core.metrics.jfr.FlightRecorderApplicationStartup;
@SpringBootApplication
public class StartupDiagnosisApplication {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(StartupDiagnosisApplication.class);
String mode = System.getProperty("startup.tracking", "buffering");
// The capacity is the number of *steps*, not beans, and there is no way to change it
// after construction. See docs/07-failure-modes.md for what a too-small buffer does.
int capacity = Integer.getInteger("startup.buffer", 16384);
switch (mode) {
case "buffering" -> app.setApplicationStartup(new BufferingApplicationStartup(capacity));
case "jfr" -> app.setApplicationStartup(new FlightRecorderApplicationStartup());
default -> { }
}
app.run(args);
}
}

View File

@@ -0,0 +1,30 @@
package com.ankurm.startup.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/** A trivial entity. Present so that Hibernate's bootstrap shows up in the step tree. */
@Entity
@Table(name = "orders")
public class Order {
@Id
private Long id;
private String customer;
private String status;
public Long getId() {
return id;
}
public String getCustomer() {
return customer;
}
public String getStatus() {
return status;
}
}

View File

@@ -0,0 +1,17 @@
package com.ankurm.startup.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* Every derived query method here is parsed and turned into a proxy during
* {@code spring.data.repository.init}. See docs/05-what-else-is-in-there.md.
*/
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByStatus(String status);
List<Order> findByCustomerAndStatusOrderByIdDesc(String customer, String status);
long countByStatus(String status);
}

View File

@@ -0,0 +1,29 @@
package com.ankurm.startup.slow;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import org.springframework.stereotype.Component;
/**
* Real CPU work in a constructor. See docs/03-the-four-phases.md.
*
* <p>Every production codebase has one of these: a bean that derives or unwraps a key
* on the way up. It is not a bug, it is not fixable by tuning Spring, and it is
* invisible in the single startup number Boot logs.
*/
@Component
public class KeystoreLoader {
private final byte[] key;
public KeystoreLoader() throws Exception {
SecretKeyFactory f = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
PBEKeySpec spec = new PBEKeySpec(
"startup-diagnosis".toCharArray(), "ankurm.com".getBytes(), 600_000, 256);
this.key = f.generateSecret(spec).getEncoded();
}
public int keyLength() {
return key.length;
}
}

View File

@@ -0,0 +1,29 @@
package com.ankurm.startup.slow;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
import org.springframework.stereotype.Component;
/**
* Compiles a rule set at construction time. See docs/03-the-four-phases.md.
*
* <p>This bean depends on nothing, which matters: it is a leaf, so lazy initialisation
* can defer it, and a reader comparing the eager and lazy transcripts will see it
* disappear from the startup tree entirely.
*/
@Component
public class LegacyRulesEngine {
private final List<Pattern> rules = new ArrayList<>();
public LegacyRulesEngine() {
for (int i = 0; i < 4_000; i++) {
rules.add(Pattern.compile("^(rule" + i + ")[-_]([a-z]{2,8})\\d{0,4}(?:/(v[0-9]+))?$"));
}
}
public int ruleCount() {
return rules.size();
}
}

View File

@@ -0,0 +1,22 @@
package com.ankurm.startup.slow;
import org.springframework.stereotype.Component;
/**
* Depends on the two slow beans above, so it appears *after* them in the step tree
* and inherits their latency in wall-clock terms without contributing any itself.
* See docs/04-reading-the-step-tree.md for why self time and total time differ here.
*/
@Component
public class ReportTemplateRegistry {
private final int templates;
public ReportTemplateRegistry(LegacyRulesEngine rules, TariffCacheWarmer tariffs) {
this.templates = rules.ruleCount() + tariffs.size();
}
public int templates() {
return templates;
}
}

View File

@@ -0,0 +1,30 @@
package com.ankurm.startup.slow;
import jakarta.annotation.PostConstruct;
import java.util.HashMap;
import java.util.Map;
import org.springframework.stereotype.Component;
/**
* The classic @PostConstruct cache warm. See docs/03-the-four-phases.md.
*
* <p>Note where the cost lands in the step tree: inside {@code spring.beans.instantiate}
* for this bean, not in a step of its own. @PostConstruct is part of instantiation as far
* as {@code ApplicationStartup} is concerned.
*/
@Component
public class TariffCacheWarmer {
private final Map<String, String> tariffs = new HashMap<>();
@PostConstruct
void warm() {
for (int i = 0; i < 400_000; i++) {
tariffs.put("tariff-" + i, Integer.toHexString(i * 31).intern());
}
}
public int size() {
return tariffs.size();
}
}

View File

@@ -0,0 +1,22 @@
package com.ankurm.startup.web;
import com.ankurm.startup.domain.OrderRepository;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/** The application's actual job, such as it is. */
@RestController
public class OrderController {
private final OrderRepository orders;
public OrderController(OrderRepository orders) {
this.orders = orders;
}
@GetMapping("/orders/summary")
public Map<String, Object> summary() {
return Map.of("total", orders.count(), "shipped", orders.countByStatus("SHIPPED"));
}
}

View File

@@ -0,0 +1,127 @@
package com.ankurm.startup.web;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup;
import org.springframework.boot.context.metrics.buffering.StartupTimeline;
import org.springframework.core.metrics.ApplicationStartup;
import org.springframework.core.metrics.StartupStep;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* What {@code /actuator/startup} will not tell you: self time.
*
* <p>The actuator endpoint hands back a flat list of timed steps, each with a total
* duration. Because steps nest, those durations double-count: the duration of
* {@code spring.beans.instantiate orderController} includes the time spent building
* every dependency it pulled in. Sorting that list by duration therefore ranks
* <em>containers</em>, not culprits.
*
* <p>This endpoint subtracts each step's direct children from its own duration, which
* is the number you actually want. See docs/04-reading-the-step-tree.md.
*
* <p><strong>Delete this before shipping.</strong> It exposes bean names and wiring.
*/
@RestController
public class StartupDiagnosticsEndpoint {
private final ApplicationStartup startup;
/**
* Spring Boot registers whatever {@code ApplicationStartup} you installed as a singleton
* named {@code applicationStartup} before the context refreshes, so it can simply be
* injected -- no {@code @Bean} method needed. Declaring one produces
* {@code required a single bean, but 2 were found}. The parameter is typed as the
* interface because the singleton is a {@code DefaultApplicationStartup} when tracking
* is off, and asking for the buffering subtype would then stop the application booting.
*/
public StartupDiagnosticsEndpoint(ApplicationStartup startup) {
this.startup = startup;
}
@GetMapping("/diag/startup")
public Map<String, Object> startup(@RequestParam(defaultValue = "15") int top) {
if (!(this.startup instanceof BufferingApplicationStartup buffering)) {
return Map.of("error", "no BufferingApplicationStartup installed",
"applicationStartup", this.startup.getClass().getName(),
"hint", "run with -Dstartup.tracking=buffering");
}
StartupTimeline timeline = buffering.getBufferedTimeline();
List<StartupTimeline.TimelineEvent> events = timeline.getEvents();
// Sum of direct children, keyed by parent step id.
Map<Long, Long> childNanos = new HashMap<>();
for (StartupTimeline.TimelineEvent event : events) {
Long parent = event.getStartupStep().getParentId();
if (parent != null) {
childNanos.merge(parent, event.getDuration().toNanos(), Long::sum);
}
}
List<Map<String, Object>> rows = new ArrayList<>();
Map<String, long[]> byName = new HashMap<>(); // [count, selfNanos]
for (StartupTimeline.TimelineEvent event : events) {
StartupStep step = event.getStartupStep();
long total = event.getDuration().toNanos();
long self = Math.max(0, total - childNanos.getOrDefault(step.getId(), 0L));
byName.computeIfAbsent(step.getName(), k -> new long[2]);
byName.get(step.getName())[0]++;
byName.get(step.getName())[1] += self;
Map<String, Object> row = new LinkedHashMap<>();
row.put("name", step.getName());
row.put("id", step.getId());
row.put("parentId", step.getParentId());
row.put("totalMs", ms(total));
row.put("selfMs", ms(self));
Map<String, String> tags = new LinkedHashMap<>();
step.getTags().forEach(t -> tags.put(t.getKey(), t.getValue()));
row.put("tags", tags);
rows.add(row);
}
List<Map<String, Object>> slowestSelf = rows.stream()
.sorted(Comparator.comparingDouble(r -> -(double) r.get("selfMs")))
.limit(top)
.toList();
List<Map<String, Object>> slowestTotal = rows.stream()
.sorted(Comparator.comparingDouble(r -> -(double) r.get("totalMs")))
.limit(top)
.toList();
List<Map<String, Object>> phases = byName.entrySet().stream()
.sorted(Comparator.comparingLong(e -> -e.getValue()[1]))
.map(e -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("step", e.getKey());
m.put("count", e.getValue()[0]);
m.put("selfMs", ms(e.getValue()[1]));
return m;
})
.toList();
Map<String, Object> out = new LinkedHashMap<>();
out.put("recordedSteps", events.size());
out.put("timelineStart", timeline.getStartTime().toString());
out.put("wallClockMs", ms(events.stream()
.mapToLong(e -> e.getDuration().toNanos()).max().orElse(0)));
out.put("phasesBySelfTime", phases);
out.put("slowestBySelfTime", slowestSelf);
out.put("slowestByTotalTime", slowestTotal);
return out;
}
private static double ms(long nanos) {
return Math.round(Duration.ofNanos(nanos).toNanos() / 10_000.0) / 100.0;
}
}

View File

@@ -0,0 +1,6 @@
# Defers every bean that nothing eagerly requires. The startup number drops; the work
# does not disappear, it moves to the first request that needs it.
# See docs/06-what-actually-helps.md.
spring:
main:
lazy-initialization: true

View File

@@ -0,0 +1,3 @@
# Nothing here: the profile exists so run.sh has a name to pass when the run should
# carry no ApplicationStartup at all. Tracking is chosen by -Dstartup.tracking, not
# by a Spring property -- see docs/02-turning-instrumentation-on.md for why.

View File

@@ -0,0 +1,28 @@
spring:
application:
name: startup-diagnosis
datasource:
url: jdbc:h2:mem:orders;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: none
open-in-view: false
sql:
init:
mode: always
management:
endpoints:
web:
exposure:
# 'startup' is not web-exposed by default, and it does nothing at all unless a
# BufferingApplicationStartup was installed before the context refreshed.
include: health,info,startup,beans,conditions
endpoint:
health:
show-details: always
logging:
level:
root: INFO

View File

@@ -0,0 +1,6 @@
-- MERGE rather than INSERT: the H2 database is kept open for the life of the JVM
-- (DB_CLOSE_DELAY=-1), so a second application context in the same JVM -- which is exactly
-- what the contract tests build -- would otherwise fail on a duplicate primary key.
MERGE INTO orders KEY(id) VALUES (1, 'acme', 'SHIPPED');
MERGE INTO orders KEY(id) VALUES (2, 'acme', 'PENDING');
MERGE INTO orders KEY(id) VALUES (3, 'globex', 'SHIPPED');

View File

@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS orders (
id BIGINT PRIMARY KEY,
customer VARCHAR(64),
status VARCHAR(32)
);

View File

@@ -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);
}
}
}