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,25 @@
#!/usr/bin/env bash
# What a too-small buffer does. Every guide picks 2048; nobody says what happens when
# an application produces more steps than that.
set -uo pipefail
source "$(dirname "$0")/env.sh"
for cap in 2048 16384; do
"$(dirname "$0")/stop.sh"
"$JAVA_HOME/bin/java" -Dstartup.tracking=buffering -Dstartup.buffer=$cap \
-jar "$JAR" > /tmp/buf.log 2>&1 &
PID=$!
for _ in $(seq 1 240); do curl -fs -o /dev/null localhost:8080/actuator/health && break; sleep 0.5; done
ok=$?
echo "--- capacity $cap ---"
if grep -q 'Started StartupDiagnosisApplication' /tmp/buf.log; then
echo -n " started, recorded steps: "
curl -s 'localhost:8080/diag/startup?top=1' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['recordedSteps'])"
else
echo " application did not start. Last lines of the log:"
grep -E 'ERROR|Exception|Caused by' /tmp/buf.log | head -5 | sed 's/^/ /'
fi
kill -9 $PID 2>/dev/null; wait $PID 2>/dev/null
done
true

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Records the same startup as JFR events instead of an in-memory buffer, then reads the
# recording with the JDK's own `jfr` tool -- no Mission Control, no extra dependency.
set -uo pipefail
source "$(dirname "$0")/env.sh"
REC=/tmp/startup.jfr
rm -f "$REC"
"$(dirname "$0")/stop.sh"
"$JAVA_HOME/bin/java" \
-XX:StartFlightRecording=filename=$REC,settings=profile,dumponexit=true \
-Dstartup.tracking=jfr -jar "$JAR" > /tmp/startup-app.log 2>&1 &
PID=$!
for _ in $(seq 1 180); do curl -fs -o /dev/null localhost:8080/actuator/health && break; sleep 0.5; done
grep -h 'Started StartupDiagnosisApplication' /tmp/startup-app.log | sed 's/^.*: //'
kill -TERM $PID 2>/dev/null; wait $PID 2>/dev/null
sleep 2
echo
echo "=== jfr summary (Spring rows only) ==="
"$JAVA_HOME/bin/jfr" summary "$REC" | head -3
"$JAVA_HOME/bin/jfr" summary "$REC" | grep -i -E 'spring|Event Count|=====' | head -10
echo
echo "=== the event type, in full ==="
"$JAVA_HOME/bin/jfr" metadata "$REC" | grep -A22 '@Name("org.springframework' | head -24
echo
echo "=== the selector matters: the event is named by its FQCN, not 'StartupEvent' ==="
echo -n " --events StartupEvent -> "
"$JAVA_HOME/bin/jfr" print --events StartupEvent --json "$REC" \
| python3 -c "import sys,json;print(len(json.load(sys.stdin)['recording']['events']),'events')"
echo -n " --events 'org.springframework.core.*' -> "
"$JAVA_HOME/bin/jfr" print --events 'org.springframework.core.metrics.jfr.FlightRecorderStartupEvent' --json "$REC" \
| python3 -c "import sys,json;print(len(json.load(sys.stdin)['recording']['events']),'events')"
echo
echo "=== 8 slowest startup steps, straight out of the recording ==="
"$JAVA_HOME/bin/jfr" print --events 'org.springframework.core.metrics.jfr.FlightRecorderStartupEvent' \
--json "$REC" > /tmp/startup-jfr.json 2>/dev/null
python3 - <<'PY'
import json
d = json.load(open('/tmp/startup-jfr.json'))
ev = d['recording']['events']
print(f"{len(ev)} StartupEvent records in the recording\n")
rows = []
for e in ev:
v = e['values']
rows.append((v.get('duration', 0), v.get('name'), v.get('tags') or ''))
rows.sort(reverse=True)
print(f"{'duration':>14} name / tags")
for dur, name, tags in rows[:8]:
print(f"{dur!s:>14} {name} {tags}")
PY
echo
echo "=== what JFR gives you that the buffer does not: JVM context in the same file ==="
"$JAVA_HOME/bin/jfr" summary "$REC" | grep -E 'GCPhasePause|ClassLoad|JavaMonitorEnter|ExecutionSample|Compilation ' | head -6
echo
echo -n " total GC pause time during this startup: "
"$JAVA_HOME/bin/jfr" print --events jdk.GCPhasePause --json "$REC" | python3 -c "
import sys, json, re
ev = json.load(sys.stdin)['recording']['events']
tot = 0.0
for e in ev:
m = re.match(r'PT(?:(\d+)M)?([\d.]+)S', e['values']['duration'])
tot += (int(m.group(1) or 0) * 60 + float(m.group(2)))
print(f'{len(ev)} events, {tot*1000:.1f} ms')"
true

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Quantifies what component scanning costs, by adding classes to the scanned package
# and changing nothing else.
#
# baseline : the application as committed
# +5000 plain : 5000 classes with no annotations -- pure scan cost
# +5000 @Component : the same 5000, now bean definitions -- scan + define + instantiate
# +5000 @Component, lazy : lazy init removes the instantiation, not the scan
#
# Each variant is a full rebuild. Budget about ten minutes.
set -uo pipefail
source "$(dirname "$0")/env.sh"
cd "$ROOT"
RUNS="${RUNS:-3}"
measure() { # measure <label> <extra java args...>
local label="$1"; shift
local started=() parse="" inst="" steps=""
for i in $(seq 1 "$RUNS"); do
"$ROOT/scripts/stop.sh"
"$JAVA_HOME/bin/java" -Dstartup.tracking=buffering "$@" -jar "$JAR" > /tmp/scan-app.log 2>&1 &
local pid=$!
for _ in $(seq 1 240); do curl -fs -o /dev/null localhost:8080/actuator/health && break; sleep 0.5; done
started+=("$(grep -ho 'in [0-9.]* seconds' /tmp/scan-app.log | head -1 | awk '{print $2}')")
if [ "$i" = "1" ]; then
curl -s 'localhost:8080/diag/startup?top=1' > /tmp/scan-diag.json
read -r parse inst steps <<<"$(python3 - <<'PY'
import json
d = json.load(open('/tmp/scan-diag.json'))
p = {r['step']: r for r in d['phasesBySelfTime']}
print(p.get('spring.context.config-classes.parse', {}).get('selfMs', 0),
p.get('spring.beans.instantiate', {}).get('selfMs', 0),
d['recordedSteps'])
PY
)"
fi
kill -9 $pid 2>/dev/null; wait $pid 2>/dev/null
done
printf '%-26s | %-18s | %9s | %9s | %6s\n' \
"$label" "$(IFS=,; echo "${started[*]}")" "$parse" "$inst" "$steps"
}
hdr() {
printf '%-26s | %-18s | %9s | %9s | %6s\n' \
"variant" "Started in (s)" "parse ms" "instMs" "steps"
printf -- '---------------------------+--------------------+-----------+-----------+-------\n'
}
echo "runs per variant: $RUNS (first run of each also queried for phase self times)"
echo
hdr
./scripts/gen-bulk.sh 0 plain >/dev/null 2>&1 || true
rm -rf src/main/java/com/ankurm/startup/bulk
mvn -B -q -DskipTests package > /tmp/scan-build.log 2>&1
measure "baseline"
./scripts/gen-bulk.sh 5000 plain > /dev/null
mvn -B -q -DskipTests package > /tmp/scan-build.log 2>&1
measure "+5000 plain classes"
./scripts/gen-bulk.sh 5000 component > /dev/null
mvn -B -q -DskipTests package > /tmp/scan-build.log 2>&1
measure "+5000 @Component"
measure "+5000 @Component, lazy" -Dspring.profiles.active=lazy
rm -rf src/main/java/com/ankurm/startup/bulk
mvn -B -q -DskipTests package > /tmp/scan-build.log 2>&1
echo
echo "bulk package removed; jar rebuilt at baseline."
true

View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Boots with BufferingApplicationStartup, then dumps the step tree three ways:
# self time by phase, slowest steps by self time, and the same list by total time
# (which is what /actuator/startup would have you sort by).
set -uo pipefail
source "$(dirname "$0")/env.sh"
"$(dirname "$0")/stop.sh"
"$JAVA_HOME/bin/java" -Dstartup.tracking=buffering -jar "$JAR" > /tmp/startup-app.log 2>&1 &
PID=$!
for _ in $(seq 1 180); do curl -fs -o /dev/null localhost:8080/actuator/health && break; sleep 0.5; done
echo "=== Boot's own startup line ==="
grep -h 'Started StartupDiagnosisApplication' /tmp/startup-app.log | sed 's/^.*: //'
echo
echo "=== /diag/startup ==="
curl -s 'localhost:8080/diag/startup?top=12' > /tmp/diag.json
python3 - <<'PY'
import json
d = json.load(open('/tmp/diag.json'))
print(f"recorded steps: {d['recordedSteps']}\n")
print("-- self time by step name (self = duration minus direct children) --")
print(f"{'self ms':>9} {'count':>5} step")
for r in d['phasesBySelfTime']:
print(f"{r['selfMs']:9.2f} {r['count']:5d} {r['step']}")
def bean(t): return t.get('beanName') or t.get('classNames') or ''
print("\n-- slowest individual steps by SELF time (the culprits) --")
print(f"{'self ms':>9} {'total ms':>9} step / tags")
for r in d['slowestBySelfTime']:
print(f"{r['selfMs']:9.2f} {r['totalMs']:9.2f} {r['name']} {bean(r['tags'])}")
print("\n-- slowest individual steps by TOTAL time (the containers) --")
print(f"{'total ms':>9} {'self ms':>9} step / tags")
for r in d['slowestByTotalTime']:
print(f"{r['totalMs']:9.2f} {r['selfMs']:9.2f} {r['name']} {bean(r['tags'])}")
PY
echo
echo "=== actuator's own endpoint: GET peeks, POST drains ==="
for verb in GET POST POST GET; do
n=$(curl -s -X $verb localhost:8080/actuator/startup \
| python3 -c "import sys,json;print(len(json.load(sys.stdin).get('timeline',{}).get('events',[])))" 2>/dev/null || echo "-")
echo " $verb /actuator/startup -> events in response: $n"
done
kill -9 $PID 2>/dev/null
wait $PID 2>/dev/null
true

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Resolved versions, from the build rather than from the documentation.
set -uo pipefail
source "$(dirname "$0")/env.sh"
cd "$ROOT"
echo "=== java -version ==="
"$JAVA_HOME/bin/java" -version 2>&1 | grep -v 'JAVA_TOOL_OPTIONS\|Picked up'
echo
echo "=== mvn -version ==="
mvn -version 2>&1 | head -1
echo
echo "=== mvn dependency:list (selected) ==="
mvn -B dependency:list -DoutputFile=/dev/stdout -DincludeScope=runtime 2>/dev/null \
| grep -E 'spring-boot:|spring-core|spring-context|spring-beans|spring-data-jpa|hibernate-core|micrometer-core|tomcat-embed-core|h2:' \
| sed 's/^\[INFO\] *//' | sort -u
true

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# The four things people reach for, measured on the same jar.
#
# no tracking : baseline, no ApplicationStartup installed
# buffering : BufferingApplicationStartup(16384) -- what it costs to measure
# jfr : FlightRecorderApplicationStartup + an active recording
# lazy : spring.main.lazy-initialization=true
# AOT cache : JDK 25 ahead-of-time cache (JEP 483/515), trained on this app
set -uo pipefail
source "$(dirname "$0")/env.sh"
RUNS="${RUNS:-4}"
run_variant() { # run_variant <label> <extra args...>
local label="$1"; shift
local out=()
for _ in $(seq 1 "$RUNS"); do
"$ROOT/scripts/stop.sh"
"$JAVA_HOME/bin/java" "$@" -jar "$JAR" > /tmp/helps.log 2>&1 &
local pid=$!
for _ in $(seq 1 240); do curl -fs -o /dev/null localhost:8080/actuator/health && break; sleep 0.5; done
out+=("$(grep -ho 'in [0-9.]* seconds' /tmp/helps.log | head -1 | awk '{print $2}')")
kill -9 $pid 2>/dev/null; wait $pid 2>/dev/null
done
# median of the runs
local med
med=$(printf '%s\n' "${out[@]}" | sort -n | awk '{a[NR]=$1} END {print (NR%2)? a[(NR+1)/2] : (a[NR/2]+a[NR/2+1])/2}')
printf '%-34s | %-26s | %8s\n' "$label" "$(IFS=,; echo "${out[*]}")" "$med"
}
printf '%-34s | %-26s | %8s\n' "variant" "Started in (s), each run" "median"
printf -- '-----------------------------------+----------------------------+---------\n'
run_variant "no tracking" -Dstartup.tracking=none
run_variant "BufferingApplicationStartup" -Dstartup.tracking=buffering
run_variant "FlightRecorder + recording" -Dstartup.tracking=jfr \
-XX:StartFlightRecording=filename=/tmp/helps.jfr,settings=profile,dumponexit=true
run_variant "lazy-initialization" -Dstartup.tracking=none -Dspring.profiles.active=lazy
echo
echo "=== JDK 25 AOT cache (Project Leyden) ==="
"$JAVA_HOME/bin/java" -version 2>&1 | head -1
rm -f /tmp/app.aotconf /tmp/app.aot
echo "-- training run (-XX:AOTMode=record) --"
"$JAVA_HOME/bin/java" -XX:AOTMode=record -XX:AOTConfiguration=/tmp/app.aotconf \
-Dstartup.tracking=none -jar "$JAR" > /tmp/aot-train.log 2>&1 &
PID=$!
for _ in $(seq 1 240); do curl -fs -o /dev/null localhost:8080/actuator/health && break; sleep 0.5; done
curl -fs -o /dev/null localhost:8080/orders/summary || true
kill -TERM $PID 2>/dev/null; wait $PID 2>/dev/null
sleep 2
ls -la /tmp/app.aotconf 2>/dev/null || { echo "no AOT configuration produced:"; tail -5 /tmp/aot-train.log; }
echo "-- assembly run (-XX:AOTMode=create) --"
"$JAVA_HOME/bin/java" -XX:AOTMode=create -XX:AOTConfiguration=/tmp/app.aotconf \
-XX:AOTCache=/tmp/app.aot -jar "$JAR" > /tmp/aot-create.log 2>&1
tail -4 /tmp/aot-create.log
ls -la /tmp/app.aot 2>/dev/null || echo "no cache produced"
echo
if [ -f /tmp/app.aot ]; then
run_variant "AOT cache (-XX:AOTCache)" -XX:AOTCache=/tmp/app.aot -Dstartup.tracking=none
run_variant "AOT cache + lazy" -XX:AOTCache=/tmp/app.aot -Dstartup.tracking=none -Dspring.profiles.active=lazy
fi
"$ROOT/scripts/stop.sh"
true

View File

@@ -0,0 +1,7 @@
# Shared environment. Override JAVA_HOME to point at your own JDK 25.
: "${JAVA_HOME:?set JAVA_HOME to a JDK 25 installation}"
export PATH="$JAVA_HOME/bin:$PATH"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
JAR="$ROOT/target/startup-diagnosis-1.0.0.jar"
OUT="$ROOT/docs/output"
MAIN=com.ankurm.startup.StartupDiagnosisApplication

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# gen-bulk.sh <count> <plain|component>
# Writes <count> classes into the package that @SpringBootApplication already scans.
#
# 'plain' classes carry no stereotype annotation at all. They are still opened, read and
# have their annotation metadata parsed by the scanner -- which is the point: it separates
# the cost of *scanning* from the cost of *creating beans*.
set -euo pipefail
COUNT="${1:-5000}"
KIND="${2:-plain}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DIR="$ROOT/src/main/java/com/ankurm/startup/bulk"
rm -rf "$DIR"; mkdir -p "$DIR"
for i in $(seq 1 "$COUNT"); do
if [ "$KIND" = "component" ]; then
printf 'package com.ankurm.startup.bulk;\nimport org.springframework.stereotype.Component;\n@Component\npublic class Bulk%d { public int id() { return %d; } }\n' "$i" "$i" > "$DIR/Bulk$i.java"
else
printf 'package com.ankurm.startup.bulk;\npublic class Bulk%d { public int id() { return %d; } }\n' "$i" "$i" > "$DIR/Bulk$i.java"
fi
done
echo "generated $COUNT $KIND classes in src/main/java/com/ankurm/startup/bulk"

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# measure-startup.sh <runs> [-- extra java args...]
# Starts the jar, waits for the "Started ... in Ns" line, records it, stops, repeats.
set -uo pipefail
source "$(dirname "$0")/env.sh"
RUNS="${1:-5}"; shift || true
[ "${1:-}" = "--" ] && shift
for i in $(seq 1 "$RUNS"); do
LOG=$(mktemp)
"$JAVA_HOME/bin/java" "$@" -jar "$JAR" > "$LOG" 2>&1 &
PID=$!
for _ in $(seq 1 120); do
grep -q 'Started StartupDiagnosisApplication' "$LOG" && break
kill -0 $PID 2>/dev/null || break
sleep 0.25
done
grep -h 'Started StartupDiagnosisApplication' "$LOG" | sed 's/.*Started/Started/'
kill -9 $PID 2>/dev/null || true
wait $PID 2>/dev/null
rm -f "$LOG"
sleep 1
done

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Regenerate every file in docs/output/. Budget ~20 minutes; the scan-tax demo rebuilds
# the project four times and the AOT demo builds a 118 MB cache.
set -uo pipefail
source "$(dirname "$0")/env.sh"
cd "$ROOT"
mvn -B -q -DskipTests package || exit 1
./scripts/demo-versions.sh > docs/output/00-versions.txt 2>&1
# 01-api-corrections.txt is javap and compiler output, kept by hand; see docs/02.
./scripts/demo-startup-tree.sh > docs/output/02-startup-tree.txt 2>&1
./scripts/demo-jfr.sh > docs/output/03-jfr.txt 2>&1
RUNS=3 ./scripts/demo-scan-tax.sh > docs/output/04-scan-tax.txt 2>&1
RUNS=4 ./scripts/demo-what-helps.sh > docs/output/05-what-helps.txt 2>&1
./scripts/gen-bulk.sh 5000 component > /dev/null
mvn -B -q -DskipTests package || exit 1
./scripts/demo-buffer-overflow.sh > docs/output/06-buffer-overflow.txt 2>&1
rm -rf src/main/java/com/ankurm/startup/bulk
mvn -B -q -DskipTests package
./scripts/stop.sh
echo "docs/output regenerated."

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# run.sh [profiles] [tracking] e.g. ./scripts/run.sh lazy buffering
set -euo pipefail
source "$(dirname "$0")/env.sh"
"$(dirname "$0")/stop.sh"
PROFILES="${1:-}"
TRACKING="${2:-buffering}"
ARGS=(-Dstartup.tracking="$TRACKING")
[ -n "$PROFILES" ] && ARGS+=(-Dspring.profiles.active="$PROFILES")
setsid nohup "$JAVA_HOME/bin/java" "${ARGS[@]}" -jar "$JAR" > /tmp/startup-app.log 2>&1 < /dev/null &
for _ in $(seq 1 120); do
curl -fs -o /dev/null http://localhost:8080/actuator/health && break
sleep 0.5
done
echo "up: profiles='${PROFILES}' tracking='${TRACKING}' (log: /tmp/startup-app.log)"

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Stop the demo application.
#
# Do NOT match on the main class name with `pkill -f`. Any shell whose command line
# happens to contain that string -- including the one that wrote this script -- matches
# too, and gets killed. Match on the executable being java AND the jar name instead.
for p in $(ps -eo pid=,comm=,args= | awk '$2 == "java" && /startup-diagnosis-1\.0\.0\.jar/ { print $1 }'); do
kill -9 "$p" 2>/dev/null || true
done
sleep 1