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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
28
spring-boot-startup-time/src/main/resources/application.yaml
Normal file
28
spring-boot-startup-time/src/main/resources/application.yaml
Normal 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
|
||||
6
spring-boot-startup-time/src/main/resources/data.sql
Normal file
6
spring-boot-startup-time/src/main/resources/data.sql
Normal 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');
|
||||
5
spring-boot-startup-time/src/main/resources/schema.sql
Normal file
5
spring-boot-startup-time/src/main/resources/schema.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id BIGINT PRIMARY KEY,
|
||||
customer VARCHAR(64),
|
||||
status VARCHAR(32)
|
||||
);
|
||||
@@ -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