performance-optimization
Spring Boot 4 / JVM performance work — measure first, then fix. Profiling (async-profiler, JFR, JMH), Micrometer, common Spring antipatterns (N+1, unbounded lists, HikariCP sizing, virtual-thread pinn
SKILL.md.claude/skills/performance-optimization/SKILL.md
GitHubPerformance optimization (Spring Boot 4 / JVM)
Rule zero: measure first
No optimization without a profile or a benchmark. "It feels slow" is a hypothesis, not a finding.
If a PR labeled perf lands without a profile artifact, a JMH result, a flame graph, or a before/after metric screenshot, request changes. The artifact path goes in the PR description and in 05-implementation-log.md.
Acceptable evidence:
- async-profiler flame graph (CPU or alloc) saved as SVG/HTML.
- JDK Flight Recorder recording (
.jfr) with the relevant view captured. - JMH benchmark output (
*.jsonfrom-rf json) with before/after. - Micrometer histogram screenshots (p50/p95/p99) from before and after.
- Database
EXPLAIN ANALYZEoutput for the query in question.
SLO-first, not optimization-first
Every perf claim is tied to a Service Level Objective. If there is no stated SLO, the work has no exit criterion — write the SLO first.
Template (the user fills the blanks; never invent the numbers):
Endpoint: <method> <path>
p50 latency: ___ ms (current: ___ ms)
p95 latency: ___ ms (current: ___ ms)
p99 latency: ___ ms (current: ___ ms)
Throughput: ___ rps (current: ___ rps)
Error budget: ___ % (window: ___ )
Measurement: <Micrometer metric name + dashboard link>
If the user has not committed to numbers, file a Q-NNN in 01-spec.md or 03-design.md and halt.
Tools
| Tool | Use for | Notes |
|---|---|---|
| async-profiler | CPU and allocation flame graphs in production-like envs | -e cpu or -e alloc; safe in prod (sample-based) |
| JFR + JDK Mission Control | Always-on low-overhead recording; method profiling, allocation, GC, locks | Enable with -XX:StartFlightRecording=... |
| JMH | Microbenchmarks for hot loops, parsers, serialization, comparator/equality changes | Fork ≥ 2, warmup ≥ 5, measure ≥ 5; run on the deploy hardware class |
| Micrometer + Spring Boot Actuator | Production metrics (latency histograms, throughput, error rate) | Use Timer with publishPercentileHistogram(true); not Counter for latency |
EXPLAIN ANALYZE |
Real query plan with row counts, buffers, timing | PostgreSQL: EXPLAIN (ANALYZE, BUFFERS, VERBOSE) ... |
-Djdk.tracePinnedThreads=full |
Detect virtual-thread pinning during dev/test | Enable in test profiles when investigating throughput regressions |
-Xlog:gc* or JFR GC events |
GC pause analysis | Look at p99 pause, not average |
Antipatterns to scan for in review
Data access
- N+1 queries. Entity navigation inside a loop or stream. Fix with
JOIN FETCH, an entity graph, or a projection DTO query. - Unbounded list endpoints. Any
List<T>return type from a controller withoutPageable. Pagination is mandatory; reject the diff. - Long-held
@Transactionalon read paths. A read transaction wrapping an HTTP boundary call holds a connection. Move I/O outside the transaction. - Eager fetch by default.
FetchType.EAGERon associations forces every query to load the world. Default toLAZY; load explicitly. - Derived queries with > 3 predicates. Spring Data parses them at startup but they hide intent and rarely use the right index. Use
@Query. - No
setFetchSizeon streaming reads. AStream<T>query that materializes all rows defeats streaming. Set fetch size; iterate; close.
Connection pool (HikariCP)
Size the pool with the formula:
connections = ((core_count * 2) + effective_spindle_count)
For most cloud Postgres instances on SSD, effective_spindle_count = 1. Start with maximumPoolSize = (cores * 2) + 1, never the Hikari default of 10 in production without measuring. Verify saturation with the Actuator hikaricp metrics (active, pending, usage).
Virtual threads (Boot 4 default-on)
- Pinning via
synchronized.synchronizedblocks holding I/O pin the carrier thread. Replace withReentrantLockfor any block that does I/O. Detect with-Djdk.tracePinnedThreads=full. - Large
ThreadLocalstate. Virtual threads are cheap, so there can be millions. AThreadLocal<byte[]>of 1 MB × 1M threads = 1 TB. Use scoped values (ScopedValue) or pool the buffer explicitly. - Custom
Executorthat pins to platform threads. Don't introduce one without an ADR and a measurement showing virtual threads were the bottleneck.
Caching
- Cache the result, not the input. Key on the inputs that affect the result; do not cache by request object identity.
- TTL is mandatory. No infinite-TTL caches in production code. State the TTL in the bean configuration.
- Stampede protection. Use
CaffeinewithrefreshAfterWriteplusAsyncLoadingCache, or a single-flight wrapper. Without it, a TTL expiry hits the origin N times concurrently. - Two-tier cache (local + Redis). Local for hot keys (microseconds), Redis for shared invalidation. Never invalidate Redis without invalidating local — use a pub/sub or short local TTL.
- Cache size cap. Bound
maximumSize(Caffeine) ormaxmemory(Redis); without a cap a cache becomes a leak.
Payload + serialization
- Stream large responses. Don't materialize a 100 MB list into memory; use
StreamingResponseBodyorResponseBodyEmitter. - Compress text responses. Enable
server.compression.enabled=trueforapplication/json,text/*over a threshold (e.g. 1 KiB). - Avoid round-tripping JSON through
Map<String, Object>. It allocates per-field; use records or a typed DTO. - Don't log full payloads at INFO. Logs are an allocation hot path; large payloads at INFO will dominate p99.
GC and heap
- Pick the collector for the workload. Throughput-bound batch: G1. Latency-bound services with > 4 GB heap: ZGC. Generational ZGC is the default to consider on Java 25.
- Container memory awareness.
-XX:+UseContainerSupportis on by default; verify withjava -Xlog:os+container=trace. Set-XX:MaxRAMPercentagerather than-Xmxso pod resizes are honored. - Allocation rate, not heap size, is the GC driver. Use JFR
Allocation in new TLABto find the allocator. Common offenders: per-requestObjectMapper,String.formatin hot paths, autoboxing in stream pipelines.
HTTP clients
- Connection pool sized for the dependency.
RestClient/@HttpExchangeover Apache HttpClient: setmaxTotalandmaxPerRoute; don't accept defaults. - Timeouts on every external call. Connect, read, write, and request timeouts. Spring Boot 4 defaults are bounded but verify per client.
- Circuit breaker on flaky dependencies. Resilience4j with bulkhead. The breaker's open-state timeout must be shorter than the upstream's timeout.
Cross-cutting checks for /review
When reviewing a diff, ask:
- Does any controller method return
List<T>without pagination? → blocker. - Does any service iterate an entity collection and call a method that triggers a query? → likely N+1; major.
- Did Hikari
maximumPoolSizechange without a measurement? → request the profile. - Did the diff add
synchronizedaround an I/O call? → request switch toReentrantLock. - Did the diff add a
@Cacheablewithout a TTL or a size cap? → blocker. - Did the diff add an external HTTP call without explicit timeouts? → blocker.
- Does any new metric use
Counterfor a duration? → request aTimerwith histogram.
Before/after evidence
Every perf change appends to 05-implementation-log.md:
### T-NNN — perf
Hypothesis: <what we expected to improve>
Before: <metric snapshot or JMH result, with units>
Change: <what was changed, in one paragraph>
After: <same metric, same workload, with units>
Evidence: <path/url to flame graph, JFR file, or JMH JSON>
SLO impact: <p95 latency moved from ___ ms to ___ ms, error budget unchanged>
A perf change without this block fails review.
Anti-rationalizations
| Excuse | Counter |
|---|---|
| "I know this loop is hot, let me micro-optimize." | Profile first. The hot loop is rarely where the time goes. |
| "Caffeine has a sensible default, no need to tune." | Default maximumSize = unbounded is a leak. Set the cap. |
| "Virtual threads make pool sizing irrelevant." | DB connection pools are still bounded. Sizing matters more, not less, because virtual threads make starvation cheaper to hit. |
| "JMH is overkill for this small change." | A 100 ns regression × 10k rps = 1 second/sec wasted. JMH is the cheapest way to know. |
| "Pagination breaks the UI." | The UI breaks because the dataset grew. Pagination is the fix; coordinate with frontend. |
| "We'll add metrics later." | If it's not measured it's not optimized. Add the Timer in the same PR. |
Verification (before claiming the work is done)
- An SLO is written down with concrete numbers.
- A measurement artifact (profile, JFR, JMH, dashboard screenshot, EXPLAIN) is linked.
- A before/after delta is recorded in
05-implementation-log.md. - The change is gated by a feature flag if it is risky or behavior-altering.
- No new antipattern from the lists above was introduced.
- The review rubric Section 10 (Performance) checks all pass.