Profiling Toolbox
CPU, allocation, lock and wall-clock profiles - what each one can and cannot see - demonstrated on one deliberately slow Java program with a real JFR recording, flame graphs drawn from the actual samples, thread dumps, a class histogram diff and two measured fixes. Includes runbooks for "CPU is high" and "memory keeps growing", and an async-profiler how-to (not run here).
The Service Is Slow and Nobody Knows Why
The symptom. On the lab host, a stripped-down OrderService request handler (SlowOrderService, eight worker threads) delivers 172-208 requests/s and each request spends 38-46 ms in the handler. The target is more than 240 req/s. Nobody has a hypothesis, only opinions: "it is the string building", "it is that lock", "it is the payment gateway".
The program was written with five deliberately different problems: an O(n squared) duplicate check (CPU), string concatenation in a loop (allocation), one global lock held for 1 ms (contention), a 30 ms blocking downstream call (waiting), and a static list that never shrinks (leak). Each is invisible to some profilers and obvious to others. This page uses the real JFR recording of that program, real thread dumps and a real class histogram to show which tool answers which question - and a decision flow for the two classic incidents, "CPU is high" and "memory keeps growing".
- CPU profile (JFR) and a flame graph drawn from the actual samples; fix the top frame, prove it.
- Wall-clock view: why that fix cannot get the request below 32 ms, shown with JFR sleep events and a poor-man's profile from thread dumps.
- Allocation profile and the GC log effect of the second fix.
- Lock profile and thread-dump evidence (BLOCKED vs WAITING vs RUNNABLE).
- Heap analysis: class histogram diff and heap dump for the leak; Eclipse MAT workflow (described, not run here).
- async-profiler: cpu / alloc / lock / wall modes as a how-to. The binary is not available in this sandbox, so none of its output appears here.
Four Profile Types and Thread States
A profile is a statistical sample of where threads are. What it samples decides what it can see.
ObjectAllocationSample, weighted by size)java.util.concurrent locks| Thread state (jstack / jcmd) | Meaning | Typical cause | Shows in CPU profile? |
|---|---|---|---|
| RUNNABLE | ready to run or running; also blocked in native socket read (a JVM quirk) | real computation - or a thread parked in socketRead0 | Only if on a CPU; socket waits can show as RUNNABLE |
| BLOCKED (on object monitor) | waiting to enter a synchronized block | lock contention | No |
| WAITING | waiting indefinitely: Object.wait(), LockSupport.park() | idle pool worker, waiting on a future or a ReentrantLock | No |
| TIMED_WAITING | waiting with a timeout: sleep, timed wait/park | sleep, scheduled pool wait, poll with timeout | No |
- Believing RUNNABLE means CPU. A thread blocked in a native socket read is reported RUNNABLE. Diagnose: look at the top frame (
SocketInputStream.socketRead0,epollWait) and compare thread CPU time (cpu=in the dump header) between two dumps. - Profiling a different workload. A profile of an idle service profiles the idle loop. Capture while the load test runs.
- Safepoint bias. Older sampling profilers could only sample at safepoints and mis-attribute time; JFR and async-profiler use asynchronous sampling (AsyncGetCallTrace-style) and avoid most of it. Prefer them to old-school jstack-loop sampling for CPU.
JFR: Recording and Reading Without Installing Anything
Java Flight Recorder ships with the JDK (open source since JDK 11), is designed to be always-on-capable and exposes jcmd, jfr command-line tools and Mission Control for GUI analysis. In JDK 21 there is also jfr view for canned tabular views - used throughout this page.
| Question | JFR event | View |
|---|---|---|
| Hot code | jdk.ExecutionSample | hot-methods |
| Allocation sites | jdk.ObjectAllocationSample | allocation-by-site, allocation-by-class |
| Lock waits | jdk.JavaMonitorEnter, jdk.JavaMonitorWait, jdk.ThreadPark | contention-by-site |
| Sleeps / waits | jdk.ThreadSleep | jfr print --events jdk.ThreadSleep |
| GC behaviour | jdk.GarbageCollection, jdk.GCPhasePause | gc-pauses, gc |
| File / socket I/O above a threshold | jdk.FileRead, jdk.SocketRead | enabled in the profile settings with a threshold |
- Recording too briefly. JFR's Java sampler runs at roughly 10-20 ms intervals depending on settings; a few seconds of data on a small service gives only hundreds of samples. Record at least 30-60 s of steady load.
- Assuming JFR is free. Overhead is low by design but not zero, especially with
profilesettings and extra events; measure with and without (the lab ran throughput numbers without JFR). - Recording the start-up only. Warm-up (class loading, JIT) dominates the first minute; wait for steady state or use
delay=.
From Flame Graph to the One Line That Matters
SlowOrderService, 8 worker threads, baseline: 176.6 and 195.1 req/s in two early runs, then 208.3 and 172.2 req/s with timing instrumentation - on a host that was running other jobs. Per request: 38.4 ms and 46.3 ms wall, 5.9 and 7.2 ms CPU, 454 KB allocated.
Hypothesis: the CPU part of the request (6-7 ms) is spent in one hot spot; removing it will cut CPU per request sharply and raise throughput. (Spoiler: the first half is right, the second half only partly.)
Record 20 s under load with settings=profile and ask for hot methods:
Method-level view hides the call path. A flame graph aggregates the full stacks: each rectangle is a frame, its width the number of samples that included it, and the stack grows upward. The graph below is drawn directly from jfr print --json output of the same recording.
ArrayList.indexOfRangeis the leaf of 1,741 of 1,951 samples (89.2 %); walking up the stack:ArrayList.containscalled fromSlowOrderService.validate.- The code is
seen.contains(v)inside a loop overseen: O(n squared) - 2,500 elements means about 3 million comparisons per request. - Second place:
audit()at 7.3 % of samples. That is the 1 ms busy-wait inside asynchronizedblock (our stand-in for work done while holding a lock). - The sleeping payment call has 1 sample in the entire CPU profile.
Replace the list with a HashSet (contains + add in O(1)). One variable, one flag in the lab program (-DfastValidate=true):
| Metric (8 threads) | Baseline | Fix 1: HashSet |
|---|---|---|
| CPU per request (ThreadMXBean) | 5.9 / 7.2 ms (2 runs) | 1.3 / 1.2 ms (2 runs) |
| Wall per request | 38.4 / 46.3 ms | 32.6 / 32.4 ms |
| Throughput | 208 / 172 req/s | 244 / 247 req/s |
| Allocated per request | 454 KB | 531 KB (up: see Chapter 06) |
CPU per request fell 5x, wall time 15-30 %, throughput +17-43 % (the baseline was noisy: loaded host). But a 5x CPU win produced only a ~1.3x latency win. Why? Chapter 05.
- Reading a flame graph by height. Height is stack depth, not time. Width is time (samples). Look for wide plateaus at the top.
- Optimising a frame that is only wide because of an upstream cause. A wide
String.formatmay be called 100x per request by a bug higher up. Walk down the stack, not just the leaf. - Inlining hides frames. JIT-inlined methods are still reported by JFR/async-profiler (via debug info), but a missing frame can mean it was inlined; look at the parent.
- Profiling a debug / warm-up run. Interpreted code shows up as frames that vanish after JIT. Profile after warm-up (page 4).
Why a Perfect CPU Fix Barely Moved the Latency
After fix 1 each request uses 1.2 ms of CPU - yet it still takes 32.5 ms of wall time and the service is stuck at ~245-255 req/s. Eight threads, 255 req/s: each thread completes a request every 31 ms. The CPU is mostly idle. A second CPU profile would find nothing to fix.
Hypothesis: the missing 31 ms is waiting, not computing. A profile that samples only running threads cannot see waiting.
Evidence 1 - JFR sleep events. jdk.ThreadSleep records every Thread.sleep with its duration and stack. In the baseline recording there are 3,405 events, one per request, 30.1 ms each:
Evidence 2 - a poor-man's wall-clock profile. Take several thread dumps while the load runs and count where the worker threads are. Eight jcmd <pid> Thread.print dumps x eight workers = 64 samples:
Evidence 3 - the CPU profile after the fix. The new recording (same program, both fixes):
- 48 of 64 thread samples (75 %) are in
callPaymentin state TIMED_WAITING (sleeping): the thread is parked inside a 30 ms downstream call. - 11 samples (17 %) were in
validateRUNNABLE - consistent with the CPU profile's 91 % share of CPU time, which is only ~15 % of wall time. - Two samples were BLOCKED on
audit()- lock contention shows only in a wall-clock or lock view. - After fix 1, the CPU profile has 275 samples (was 1,951) and the top frame is
audit()at 80.7 %: the busy-wait inside the lock - 1 ms per request - is now the "hot" code, even though the request is 97 % waiting. - A wall-clock accounting per request: 30.1 ms payment wait + 1.2 ms CPU + ~1 ms other = ~32 ms. That is the floor for 8 blocking threads: 8 / 0.0315 = 254 req/s, matching the measured 254.6.
The remaining time is a blocking call. Options (each its own experiment, in other pages): more threads or virtual threads to overlap the waits (Concurrency), make the call async or cache it (Caching), reduce its latency with connection reuse and timeouts (Spring, HTTP & Network), or fan out in parallel (Distributed). Further CPU tuning would be wasted effort.
| Check | Prediction | Measured |
|---|---|---|
| Throughput ceiling, 8 threads, 31.5 ms/request | 8 / 0.0315 = 254 req/s | 254.1 and 250.2 req/s (both fixes), 254.6 in the JFR run |
| Share of the request that is waiting | 30.1 / 32 = 94 % | ThreadSleep 30.1 ms of 31.4-32.0 ms wall |
| CPU utilisation of the 12 cores | low | 1.2 ms x 254/s = 0.3 cores |
The model predicted the throughput to within 1 %. Profiling is not just "find the hot function": it is building a quantitative model of where each millisecond goes and checking it.
Not run in this sandbox: the async-profiler binary is not installed, so no output from it appears on this page. The commands below are from its documentation and are the usual way to get the same four profiles with a flame-graph output:
In wall mode the payment wait would appear as a wide Thread.sleep tower (or, in a real service, SocketInputStream.socketRead0 / epollWait beneath the HTTP client) - exactly the block this chapter's CPU flame graph lacks. In containers async-profiler needs perf_events permissions (kernel.perf_event_paranoid / CAP_SYS_ADMIN-style settings) or falls back to a less precise itimer mode - check the project documentation.
- Using only a CPU profile for a latency problem. If CPU utilisation is low and latency is high, the answer is waiting; use wall-clock, lock and trace data (page 13).
- Thread-dump sampling with too few dumps. 8 dumps give coarse percentages; take 20-50 spaced irregularly. Never conclude from one dump.
- Forgetting sleeps hidden in libraries (retry back-off, pool borrow timeouts,
Thread.sleepin a polling loop). Search the wall-clock profile forsleep/parktowers.
Garbage Is Not Free: Reading Allocation and GC Together
The baseline recording has 17 young collections in 20 seconds (18 GC events including one old-generation collection) for a service that does almost no work per request. Allocation per request is 454 KB.
Hypothesis: a few call sites produce most of the garbage; reducing them will cut allocation per request and the number of collections. Allocation is a CPU concern too: every byte must be zeroed, and every young GC pauses the application.
- 75.6 % of allocation pressure is a
byte[]allocated by string concatenation - therender()method:out += "line-" + i + ";"copies the whole string every iteration (quadratic bytes for 300 appends). - 10.9 % is
Integer.valueOf- boxing invalidate(); 6.4 %Arrays.copyOf- ArrayList growth. - Notice that
render()was only 1.2 % of the CPU profile (23 samples) but ~76 % of the allocation profile - a different profile type reorders the suspects.
| Metric | Baseline | Fix 1 only | Fix 1 + 2 |
|---|---|---|---|
| Allocated per request | 454 KB | 531 KB | 154-155 KB |
| Young GCs in a 20 s JFR run | 17 | n/a | 4 (at 5,092 requests vs 3,405) |
| Throughput | 172-208 req/s | 244-247 | 254 / 250 / 254.6 |
| CPU per request | 5.9-7.2 ms | 1.2-1.3 ms | 1.2 ms |
Allocation per request dropped 3.4x from the baseline (and 3.4x relative to fix 1). Throughput did not move beyond the 254/s wait-bound ceiling, which is the honest result: the allocation fix reduces GC work and CPU headroom, not the 30 ms payment wait. It also reveals that fix 1 made allocation worse (454 to 531 KB) - HashSet nodes plus the same boxing; that regression would have been invisible without the allocation profile. See JVM Memory & GC for what the collector then does with it.
- Optimising allocation that does not matter. Short-lived garbage in the young generation is cheap; what hurts is the rate (GC frequency) and anything that survives (promotion). Check GC pause and frequency before you rewrite code.
- Sampling bias at small sizes. JFR's allocation sampling is TLAB-based and weighted; tiny allocations are under-represented. Use
jdk.ObjectAllocationSampleweights and ensure enough samples. - Boxing in hot paths (
Integer.valueOf,Longkeys in maps, streams over boxed types) - a classic allocation source. - String concatenation in loops - quadratic copy; use
StringBuilder/String.join/Collectors.joining.
Contention: Who Waits for Whom
In the baseline, per-request wall time (38-46 ms) exceeds sleep (30.1 ms) plus CPU (5.9-7.2 ms) by 2.4-9 ms that no profile so far explains. Suspect: threads waiting for a lock or for a CPU on an oversubscribed host.
Hypothesis: the global AUDIT_LOCK serialises all eight workers for ~1 ms per request; with eight threads that is a queue.
- 105 contended acquisitions of
audit(), average wait 18.1 ms, maximum 60.8 ms. Each wait is far longer than the 1 ms critical section: a lock convoy - while the holder is descheduled (loaded host), every other thread queues. - The thread dump shows
BLOCKED (on object monitor)atSlowOrderService.audit(SlowOrderService.java:52)while another thread holds the lock - the dump names the monitor address, so you can find the holder in the same dump (- locked <0x...>). - Only 105 contentions in 3,405 requests (3 %): this lock is a tail problem, not the main cost. Averages hide it; p99 sees it (page 1).
- After fix 1 the contention nearly vanished: 3 events (avg 19 ms) in 5,092 requests. Less CPU saturation meant the lock holder was rarely descheduled while holding the lock. Contention is often a symptom of CPU pressure, not an independent cause.
| Technique | How | When |
|---|---|---|
| Shorten the critical section | move work out of the synchronized block; hold the lock only for the shared-state update | always first |
| Reduce sharing | per-thread buffers, LongAdder, sharded maps (ConcurrentHashMap) | counters, caches |
| Lock striping / read-write | ReentrantReadWriteLock, StampedLock | read-mostly state |
| Hand off asynchronously | queue + single writer thread (audit log, metrics) | logging / audit paths |
Virtual threads and synchronized | on JDK 21 a virtual thread that blocks inside synchronized pins its carrier; fixed in JDK 24 (JEP 491) | see Concurrency |
This chapter changed nothing; the verification is the recording after fix 1: jfr view contention-by-site rec2.jfr shows audit(long) with count 3, avg 19.0 ms, max 25.1 ms. Verify any real lock fix the same way: contention count and avg wait should fall, and throughput at the same CPU should rise.
- Blaming a lock without checking holder behaviour. A long wait with a 1 ms hold time means the holder was off-CPU (GC, preemption, I/O inside the lock). Diagnose: who holds it, and what is that thread doing in the same dump?
- I/O inside
synchronized. A network or disk call under a lock turns it into a global bottleneck. Diagnose: the holder's stack is insocketWrite/FileOutputStream. - Deadlock vs contention.
jcmd Thread.printends with "Found one Java-level deadlock" if two threads wait on each other forever; contention resolves, deadlock does not. - Thread pool starvation looks like WAITING workers + a growing queue, not BLOCKED threads: check pool active/queue metrics (page 6).
Class Histograms, Heap Dumps and the Leak
SlowOrderService finishes each run reporting "leaked entries: 3405" - one per request - because remember() appends an AuditEntry (with a 512-byte payload) to a static list that is never trimmed. In production the symptom would be: heap after GC creeping up, GC frequency rising, eventually java.lang.OutOfMemoryError: Java heap space.
Hypothesis: one application class's instance count rises monotonically between snapshots; a heap dump will show a single GC root path holding them.
SlowOrderService$AuditEntrygrew from 736 to 3,065 instances in 6 seconds (rank 12 to rank 5 in the histogram) whilebyte[]grew from 810 KB to 2.1 MB (the 512-byte payloads).- A class histogram says what is growing, not who holds it. For that: open the dump in Eclipse MAT or JDK Mission Control and read the dominator tree (largest retained sizes) and path to GC roots. Here it would show a static field
SlowOrderService.LEAK(anArrayList) retaining all entries. MAT was not run in this sandbox; the workflow is described from its documented features. - The heap dump file was 6.3 MB and written in 0.072 s for this tiny heap; a production dump of an 8 GB heap pauses the JVM for many seconds and needs equal free disk. Take it from an instance that has been drained from the load balancer.
Bound the structure (evict by size or age: a Caffeine cache with maximumSize/expireAfterWrite instead of a static list), or stop retaining it. Typical leak shapes: static maps used as caches, listeners/callbacks never deregistered, ThreadLocal values in pooled threads, unbounded queues, per-request objects stored in a session or MDC (see JVM Memory & GC).
A leak fix is proven when the after-GC floor of the heap stays flat over a soak test (hours) and the class count stops growing between histograms. Watch jvm.memory.used after a full GC / jvm.gc.live.data.size in production, not instantaneous usage.
GC.class_histogramandGC.heap_dumptrigger a full GC first (unless-allis passed to the histogram), so they show live objects, not garbage - good for leaks, a pause for production.- Heap dumps contain secrets (tokens, PII). Store and ship them like credentials.
- Growing RSS with flat heap is not a heap leak. Think direct buffers, thread stacks, metaspace, malloc arenas; use Native Memory Tracking (
-XX:NativeMemoryTracking=summary,jcmd VM.native_memory). - -XX:+HeapDumpOnOutOfMemoryError with
-XX:HeapDumpPathon a volume that survives the container restart - otherwise the evidence vanishes with the pod.
Diagnostic Decision Flows
Memorise the order of questions, not the commands. Each flow ends with one change followed by a re-measure.
- 1. USE check: any pool at 100 % (
hikaricp.connections.pending, Tomcat busy threads, executor queue)? That is saturation: Little's Law (page 1). - 2. Take 20+ thread dumps over a minute; count states and top application frames (Chapter 05 method). A pile of TIMED_WAITING/RUNNABLE-in-socketRead at the same call = a slow dependency; BLOCKED at one monitor = contention.
- 3. Confirm with a wall-clock profile (async-profiler
-e wall, how-to above) or a distributed trace waterfall (page 13). - 4. Fix the largest wait: timeouts, caching, concurrency, or the dependency itself. Re-measure p99, not just the mean.
| Tool | Best for | Overhead / risk | Used on this page |
|---|---|---|---|
jcmd Thread.print / jstack | a few snapshots: states, deadlocks, who holds a lock | brief safepoint pause; coarse | yes - 8 dumps |
| JFR | low-overhead production CPU / alloc / lock / GC / I/O events | low (default settings) | yes - 2 recordings |
jcmd GC.class_histogram | what is growing | forces GC by default | yes |
jcmd GC.heap_dump + MAT / JMC | who retains it (dominator tree, GC roots) | long pause on big heaps; sensitive data | dump taken; MAT not run |
| async-profiler | accurate CPU / alloc / lock / wall flame graphs, native frames | needs binary and perf permissions | no - not available here |
| Micrometer / Actuator | continuous metrics: p99, GC pause, pool pending | negligible | page 13 / Spring Actuator |
- Running the heavy tool first. Start with metrics and one thread dump; escalate to JFR, then heap dumps.
- Profiling on a freshly started instance. JIT and caches are cold; profile steady state.
- Not saving the evidence. Keep .jfr files, thread dumps and histograms with the incident notes; a flame graph is a record of what the code was doing on that day.
Try It Yourself: SlowOrderService and the Commands
Everything above comes from one 130-line, dependency-free program. Save it as SlowOrderService.java. (The baseline JFR recording was taken before the per-request timing helper timedRequest was added to the program; the request logic was identical.)
- Set
threadsto 32: with a 30 ms wait the ceiling is 32 / 0.0315, about 1,000 req/s, until the 1 ms lock (about 1,000/s max) and the CPU become the limit. Predict, run, record. - Replace
Thread.sleep(30)by a virtual-thread executor to overlap waits (page 6) and profile again: the lock becomes the top item. - Add
-Xlog:gcto a baseline run and match the GC lines to the allocation profile. - Write a script that turns
jfr print --jsonoutput into folded stacks and feed it to a flame-graph generator; the SVGs on this page were produced this way.
Profiling Questions and Model Answers
- Q: CPU is 20 % but latency is bad. What now?
The service is waiting, not computing. Check saturation (pool pending, queues), take thread dumps or a wall-clock profile and a trace; a CPU flame graph will not show the wait. In the lab 75 % of thread samples were in a sleeping downstream call while CPU was 0.3 cores. - Q: What are the four types of profile?
CPU (running threads), allocation (who creates garbage), lock (who waits for whom) and wall-clock (where elapsed time goes, all thread states). - Q: How do you read a flame graph?
Width = proportion of samples including that frame; height = stack depth; look for wide plateaus at the top and walk down to find the call path that causes them; compare two graphs rather than reading one in isolation. - Q: What is JFR and why use it in production?
A built-in event recorder in the JVM with low overhead; it captures CPU samples, allocation, locks, GC and I/O events to a file that can be analysed withjfror Mission Control. No agent or restart is needed (jcmd JFR.start). - Q: BLOCKED vs WAITING vs TIMED_WAITING vs RUNNABLE?
BLOCKED: waiting to enter a synchronized block; WAITING: waiting without timeout (wait/park); TIMED_WAITING: waiting with timeout (sleep, timed park); RUNNABLE: runnable - including threads inside native socket reads, so check the top frame. - Q: How do you find a memory leak?
Confirm the after-GC heap floor rises; diff two class histograms; heap dump and read the dominator tree and paths to GC roots; fix retention and verify with a soak test. - Q: Heap is flat but the container grows to its limit. Where is the memory?
Off-heap: direct buffers, thread stacks, metaspace, code cache, native allocations. Use Native Memory Tracking and compare with the container limit (page 12). - Q: Why did the CPU fix not improve latency much?
Because the CPU time was a small share of wall time; by Amdahl applying 5x to 15 % of the request gives ~1.14x. Do wall-clock accounting first. - Q: Is a profiler safe to run in production?
JFR with default settings is designed for it; heavier modes and heap dumps are not. Prefer short windows, one instance, and a drained instance for heap dumps.
Cheat Sheet and Recap
| Symptom | First profile | Then |
|---|---|---|
| High CPU, throughput capped | CPU flame graph (JFR / async-profiler cpu) | fix the widest top frame; re-record |
| High GC time / frequent GC | allocation profile + GC log | reduce allocation rate; tune the collector (page 3) |
| Latency high, CPU low | thread dumps / wall-clock, traces | find the wait: lock, pool, I/O, dependency |
| p99 spikes, p50 fine | lock profile + GC pauses | contention or pauses at the tail |
| Heap or RSS climbing | class histogram diff, heap dump; NMT for native | bound or release the retained objects |
| Throughput flat as threads grow | lock profile; USE on pools | shared resource (page 1 scaling laws) |
- Match profile type to symptom. A CPU profile only sees running threads: here it blamed
validate()(91.5 % of samples) while 78 % of the wall time was a 30 ms wait. - Fix one thing, re-record. Fix 1 cut CPU/request 5x and wall time ~25 %; the throughput stopped at the 8 / 0.0315 s = 254 req/s ceiling the wait model predicted.
- Allocation profile reorders suspects:
render()was 1.2 % of CPU but ~76 % of allocation; fixing it cut allocation 3.4x and young GCs from 17 to 4. - Contention is often a symptom: 105 contended acquisitions (avg 18 ms) before the CPU fix, 3 after.
- Memory growth: class histogram diff names the class (736 to 3,065 AuditEntry); dominator tree names the holder.
- async-profiler modes (cpu, alloc, lock, wall) are the production-grade flame-graph route; not run in this sandbox.
- 01 Measure First - attribution and the loop.
- 02 Benchmarking - proving a fix under load.
- 03 JVM Memory & GC - what GC does with the garbage found here.
- 06 Concurrency & Virtual Threads - overlapping the 30 ms waits.
- 13 Scaling & Observability - continuous metrics and traces.