Skip to main content
← Writing

Spring Boot · Profiling · Performance

Spring Boot Profiling: Finding the Real Bottleneck

2026-03-20 · 10 min

p95 climbed and pods got fat. How we separated CPU hotspots from memory pressure — async profiles, heap clues, and what we changed after.

When a Spring Boot service slows down, the loudest opinion in the room is usually wrong. "Add replicas." "Bump the heap." "It must be the database."

Sometimes it is the database. Sometimes it is a single method allocating like it is paid per byte. Profiling is how you stop guessing.

This is the playbook I use on JVM services (payroll-shaped APIs included): observe → classify (CPU vs memory) → profile the right signal → change one thing → re-measure.

MetricsClassifyCPU profileHeap / GCFix 1Recheck
Fig — Observe → classify CPU vs memory → profile → one change → re-measure

Step 0 — Decide what "slow" means

Before attaching a profiler, lock a question:

  • Is latency bad (p95 / p99 on a named endpoint)?
  • Is throughput collapsing under the same concurrency?
  • Are pods restarting (OOMKilled) or just "feeling heavy"?

Those three point at different tools. Treating them as one problem produces cargo-cult tuning.

CPU-bound vs memory-bound — a quick split

  • CPU % pegged, GC quiet, latency rises with load → CPU / lock / hot method
  • CPU bursty, GC time high, heap sawtooth, alloc rate wild → Allocation / GC pressure
  • Heap climbs over hours/days, Full GC cannot reclaim → Leak or retained cache
  • Latency high, CPU low, pool wait / remote timers → I/O wait, pool saturation, remote calls

Metrics first: request latency, JVM CPU, heap used, GC pause, Hikari waiting threads, Redis/DB client timers. Then profile.

CPU: find the hot method, not the hot take

On a staging box (or a canary with consent), I reach for continuous or sampled profiling — async-profiler / JFR — while replaying a realistic load (calculation window, report export, whatever hurts production).

What we look for:

  • A surprising share of samples in JSON serialization, PDF/report rendering, or regex
  • Contended locks (synchronized, unfair pools)
  • Accidental N+1 showing up as repeated repository frames

Example pattern we fixed: a "simple" DTO mapping path that rebuilt the same lookup map on every item in a batch. CPU profiles lit up that method. Caching the lookup for the request scope dropped CPU and p95 without touching the database.

Rule: if the flame graph's widest tower is your code, read it. If it is the driver or the GC, do not rewrite business logic first.

Memory: allocation rate vs leak

Two different diseases:

1) Allocation churn (death by a thousand objects)

Symptoms: young GC flapping, alloc rate high, heap recovers after GC.

Fixes that actually helped us:

  • Reuse buffers for report generation
  • Avoid building giant intermediate List/Map graphs when a stream/projection would do
  • Watch Jackson / mapping layers that clone everything "for safety"

2) Retention / leak (death by never letting go)

Symptoms: old gen climbs, Full GC does little, OOM after hours.

Fixes:

  • Heap dump → dominator tree (Eclipse MAT or similar)
  • Hunt static caches, unbounded maps, ThreadLocal leftovers, listener lists
  • Confirm Redis/Caffeine sizes and TTLs — "forever cache" is a leak with branding

On Kubernetes, also compare container memory vs heap: native memory, metaspace, and direct buffers sit outside -Xmx. A pod can OOM with "plenty of heap left."

A concrete sequence we run

  1. Grab golden latency + JVM charts for the bad endpoint
  2. Classify CPU vs GC vs I/O from metrics
  3. Record a short profile under load (minutes, not hours)
  4. Change one thing aligned with the evidence
  5. Re-run the same load script; keep the flame graph in the PR

If step 5 does not move the chart, the theory was wrong — not the profiler.

Spring-specific traps worth naming

  • Default pools that look fine in idle demos and starve under payroll bursts
  • Lazy JPA graphs that explode when a serializer touches them (CPU + alloc)
  • Actuator + unbounded custom metrics labels (memory surprise)
  • "Just increase `-Xmx`" masking a leak until the node pays for it

What we refuse to do

  • Tune without a before/after chart
  • Profile production casually without a plan for overhead and PII
  • Call something "optimized" because the code looks cleaner

Profiling is not a personality. It is a habit: measure the bottleneck you have, not the one that makes a good standup story.