Performance ยท 05

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).

12
Chapters
5
Real labs
14
Diagrams
2
JFR recordings
01
Chapter One - The Problem

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".

Hero - a CPU profile can be 100 % correct and still miss most of the latency
Same program, same 20 seconds: what each profile says about one request CPU profile (JFR ExecutionSample) validate() 91.5 % audit 7.3 % Wall-clock view of the same request (thread-time accounting) cpu payment call: Thread.sleep 30.1 ms (78 % of the wall time) - invisible to the CPU profile Fix the 91.5 % hotspot and CPU per request drops 5x - but the request is still 32 ms because 30 of them are waiting. Measured (SlowOrderService, 8 threads): wall 38.4 ms/req (run 1), cpu 5.9 ms/req (ThreadMXBean), sleep 30.1 ms (JFR ThreadSleep).

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".

Contents of the lab
  • 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.
Key insight: Profilers are different cameras: a CPU profile is a thermal camera (it only lights up what is hot), a lock profile is a traffic camera at one junction, an allocation profile is a rubbish-truck GPS trace, and a wall-clock profile is a CCTV of everything the request does including standing still. Pointing the thermal camera at a queue tells you nothing.
02
Chapter Two - Mental Model

Four Profile Types and Thread States

A profile is a statistical sample of where threads are. What it samples decides what it can see.

The four profile types and the question each answers
CPU ExecutionSample Where is the CPU time spent? sees: running threads only Allocation ObjectAllocationSample Who creates garbage / pressure? sees: new objects by call site Lock JavaMonitorEnter / ThreadPark Who waits for whom? sees: threads blocked on monitors Wall-clock thread dumps / async -e wall Where does the request wait? sees: all states incl. sleep, I/O Rule: match profile to symptom - high CPU: CPU; GC churn: allocation; low CPU + slow: lock, wall-clock.
Where one request really spends its wall time
one http-worker thread, one request (schematic from measured averages) validate Thread.sleep(30) validate 5 ms cpu render lock wait 0.6 + hold 1.0 ms 0 10 20 30 40 ms in the request (wall clock); measured total 38.4 ms in run 1 CPU profile sees the red and green bars. Lock profile sees the gold bar. Only a wall-clock view sees the blue bar - 78 % of the request.
A CPU profiler samples threads that are running (state RUNNABLE on a core)
โ–ถ
It shows hot code. Threads that sleep, wait on a lock or block on I/O are not on a CPU and do not appear. A service that is slow but idle will have a boring CPU flame graph.
An allocation profiler samples object allocations (JFR: ObjectAllocationSample, weighted by size)
โ–ถ
It shows who generates garbage. Allocation rate drives GC frequency (page 3), and GC steals CPU and adds pauses.
A lock profiler records time spent blocked entering monitors or parking on java.util.concurrent locks
โ–ถ
It shows contention: how long threads wait and which lock/stack they wait on.
A wall-clock profiler samples all threads regardless of state
โ–ถ
It shows where the elapsed time of a request goes, including sleeping, waiting for sockets and queues. This is the profile that matches a user's stopwatch.
Thread state (jstack / jcmd)MeaningTypical causeShows in CPU profile?
RUNNABLEready to run or running; also blocked in native socket read (a JVM quirk)real computation - or a thread parked in socketRead0Only if on a CPU; socket waits can show as RUNNABLE
BLOCKED (on object monitor)waiting to enter a synchronized blocklock contentionNo
WAITINGwaiting indefinitely: Object.wait(), LockSupport.park()idle pool worker, waiting on a future or a ReentrantLockNo
TIMED_WAITINGwaiting with a timeout: sleep, timed wait/parksleep, scheduled pool wait, poll with timeoutNo
โš  Common bugs & edge cases:
  • 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.
03
Chapter Three - The Tool

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.

How JFR works
JVM events ExecutionSample every 10-20 ms allocation, locks, GC, I/O thread-local buffers low overhead, lock-free recording (.jfr) ring buffer in memory dumped to a file tools jfr summary / print / view JDK Mission Control Start: java -XX:StartFlightRecording=filename=rec.jfr,settings=profile | jcmd PID JFR.start duration=60s filename=rec.jfr settings=profile settings=default (low overhead) vs settings=profile (more frequent sampling, more overhead; lab / short windows).
Starting a recording
Shell
# at startup (what the lab used) java -XX:StartFlightRecording=filename=rec.jfr,settings=profile SlowOrderService 8 20 # on a running process (production-friendly) jcmd <pid> JFR.start name=perf duration=60s filename=/tmp/perf.jfr settings=profile jcmd <pid> JFR.check jcmd <pid> JFR.dump name=perf filename=/tmp/now.jfr # snapshot without stopping jcmd <pid> JFR.stop name=perf
Reading it
Shell
jfr summary rec.jfr # which events exist, how many jfr view hot-methods rec.jfr # JDK 21: canned views (also: allocation-by-site, contention-by-site, gc-pauses, ...) jfr print --events jdk.ExecutionSample --stack-depth 12 rec.jfr jfr print --json --events jdk.ExecutionSample rec.jfr > es.json # for your own tooling (the flame graphs here were drawn from this)
Output - the recorded run (real)
[3.366s][info][jfr,startup] Started recording 1. No limit specified, using maxsize=250MB as default. [3.366s][info][jfr,startup] [3.366s][info][jfr,startup] Use jcmd 1501141 JFR.dump name=1 to copy recording data to file. pid=1501141 threads=8 seconds=20 completed 3405 requests = 170.3 req/s, leaked entries: 3405
Output - jfr summary rec.jfr (first rows, real)
Version: 2.1 Chunks: 1 Start: 2026-10-30 11:51:29 (UTC) Duration: 21 s Event Type Count Size (bytes) ============================================================= jdk.ObjectAllocationSample 4871 68041 jdk.GCPhaseParallel 4471 111520 jdk.ThreadSleep 3405 61290 jdk.ExecutionSample 1951 19510 jdk.PromoteObjectInNewPLAB 760 13138 jdk.SystemProcess 552 89797 jdk.ModuleExport 505 4921 jdk.BooleanFlag 504 15346
Output - one jdk.ExecutionSample event (real; this sample is from the main thread at start-up)
jdk.ExecutionSample { startTime = 17:21:29.492 (2026-10-30) sampledThread = "main" (javaThreadId = 1) state = "STATE_RUNNABLE" stackTrace = [ java.lang.invoke.MemberName$Factory.resolve(byte, MemberName, Class, int, boolean) line: 966 java.lang.invoke.MemberName$Factory.resolveOrFail(byte, MemberName, Class, int, Class) line: 991 java.lang.invoke.MethodHandles$Lookup.resolveOrFail(byte, MemberName) line: 3758 java.lang.invoke.MethodHandles$Lookup.linkMethodHandleConstant(byte, Class, String, Object) line: 4261 java.lang.invoke.MethodHandleNatives.linkMethodHandleConstant(Class, int, Class, String, Object) line: 627 jdk.internal.platform.CgroupUtil.readStringValue(CgroupSubsystemController, String) line: 66 ... ] }
QuestionJFR eventView
Hot codejdk.ExecutionSamplehot-methods
Allocation sitesjdk.ObjectAllocationSampleallocation-by-site, allocation-by-class
Lock waitsjdk.JavaMonitorEnter, jdk.JavaMonitorWait, jdk.ThreadParkcontention-by-site
Sleeps / waitsjdk.ThreadSleepjfr print --events jdk.ThreadSleep
GC behaviourjdk.GarbageCollection, jdk.GCPhasePausegc-pauses, gc
File / socket I/O above a thresholdjdk.FileRead, jdk.SocketReadenabled in the profile settings with a threshold
โš  Common bugs & edge cases:
  • 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 profile settings 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=.
04
Chapter Four - Lab A: CPU Profile

From Flame Graph to the One Line That Matters

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
1 ยท Problem โ€” throughput below target

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.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
2 ยท Hypothesis โ€” what we expect

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.)

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
3 ยท Measurement โ€” record and read

Record 20 s under load with settings=profile and ask for hot methods:

Output - jfr view hot-methods rec.jfr (baseline, real)
Java Methods that Executes the Most Method Samples Percent ---------------------------------------------------------------------------------- ------- ------- java.util.ArrayList.indexOfRange(Object, int, int) 1,741 89.24% SlowOrderService.audit(long) 142 7.28% java.lang.Integer.equals(Object) 21 1.08% java.lang.Integer.getChars(int, int, byte[]) 19 0.97% SlowOrderService.validate(int) 16 0.82% SlowOrderService.render(int) 2 0.10% java.lang.Class.getComponentType() 1 0.05%

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.

Flame graph from the real JFR recording (jdk.ExecutionSample, baseline)
lambda$main$1 (1945) handleRequest (1945) validate (1779) ArrayList.contains (1762) ArrayList.indexOf (1762) ArrayList.indexOfRange (1762) audit x axis = share of 1,945 CPU samples (not time order). width of validate = 1,779 samples = 91.5 %. Stack grows upward; leaf at the top.Missing: the 30 ms Thread.sleep in callPayment (1 sample). A sleeping thread is off-CPU, so it is invisible here.
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
4 ยท Diagnosis โ€” what the data showed
  • ArrayList.indexOfRange is the leaf of 1,741 of 1,951 samples (89.2 %); walking up the stack: ArrayList.contains called from SlowOrderService.validate.
  • The code is seen.contains(v) inside a loop over seen: 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 a synchronized block (our stand-in for work done while holding a lock).
  • The sleeping payment call has 1 sample in the entire CPU profile.
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
5 ยท Optimisation โ€” change one variable: the data structure

Replace the list with a HashSet (contains + add in O(1)). One variable, one flag in the lab program (-DfastValidate=true):

Java - the fix
Set<Integer> seenFast = new HashSet<>(); int dups = 0; for (int i = 0; i < n; i++) if (!seenFast.add((i * 31) % (n * 2))) dups++; return dups;
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
6 ยท Verification โ€” same test, compare
Metric (8 threads)BaselineFix 1: HashSet
CPU per request (ThreadMXBean)5.9 / 7.2 ms (2 runs)1.3 / 1.2 ms (2 runs)
Wall per request38.4 / 46.3 ms32.6 / 32.4 ms
Throughput208 / 172 req/s244 / 247 req/s
Allocated per request454 KB531 KB (up: see Chapter 06)
Before / after for the two fixes (real runs of SlowOrderService)
wall time per request (ms) 42.3 baseline 32.5 fix 1: validate 31.7 fix 1+2: + render CPU time per request (ms) 6.55 baseline 1.25 fix 1: validate 1.2 fix 1+2: + render allocation per request (KB) 454 baseline 531 fix 1: validate 154 fix 1+2: + render Baseline = mean of two 15 s runs (loaded host); fixed variants were consistent across 2 runs each. Note fix 1 INCREASED allocation (HashSet nodes + boxed Integers); fix 2 is what reduced it - one variable at a time reveals that.

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.

โš  Common bugs & edge cases:
  • 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.format may 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).
05
Chapter Five - Lab B: Wall-Clock

Why a Perfect CPU Fix Barely Moved the Latency

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
1 ยท Problem โ€” CPU is fixed, latency is not

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.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
2 ยท Hypothesis โ€” what we expect

Hypothesis: the missing 31 ms is waiting, not computing. A profile that samples only running threads cannot see waiting.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
3 ยท Measurement โ€” measure the 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:

Output - jfr print --events jdk.ThreadSleep --stack-depth 5 rec.jfr (first event, real)
jdk.ThreadSleep { startTime = 17:21:30.214 (2026-10-30) duration = 30.1 ms time = 30.0 ms eventThread = "http-worker-27" (javaThreadId = 27) stackTrace = [ java.lang.Thread.afterSleep(ThreadSleepEvent) line: 474 java.lang.Thread.sleep(long) line: 512 SlowOrderService.callPayment() line: 45 SlowOrderService.handleRequest() line: 57 SlowOrderService.lambda$main$1(long) line: 68 ... ] }

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:

Shell
for i in 1 2 3 4 5 6 7 8; do jcmd $PID Thread.print > td_$i.txt; sleep 0.3; done # then count the first application frame of every http-worker thread and its state
Poor-man's wall-clock profile from 8 thread dumps (real)
callPayment: TIMED_WAITING (sleep) 48 / 64 = 75 % validate: RUNNABLE 11 / 64 = 17 % audit: BLOCKED 2 / 64 = 3 % audit: RUNNABLE (holder) 1 / 64 = 2 % render: RUNNABLE 1 / 64 = 2 % handleRequest: RUNNABLE 1 / 64 = 2 % 8 jcmd Thread.print dumps x 8 http-worker threads = 64 samples. A poor-man's WALL-CLOCK profile: 75 % of the time is the payment wait.

Evidence 3 - the CPU profile after the fix. The new recording (same program, both fixes):

Output - jfr view hot-methods rec2.jfr (after fixes, real)
Java Methods that Executes the Most Method Samples Percent --------------------------------------------------------------- ------- ------- SlowOrderService.audit(long) 222 80.73% java.util.HashMap.resize() 17 6.18% java.util.HashMap.putVal(int, Object, Object, boolean, boolean) 13 4.73% java.lang.Integer.valueOf(int) 11 4.00% java.util.HashSet.add(Object) 4 1.45% SlowOrderService.validate(int) 2 0.73% java.lang.StringLatin1.newString(byte[], int, int) 1 0.36%
Flame graph, after the two fixes (real JFR recording)
lambda$main$1 (275) timedRequest (274) handleRequest (273) audit (222) validate (48) HashSet.add HashMap.put Both fixes: 275 samples (was 1,945). audit() is now 222/275 = 80.7 % (the 1 ms busy-wait inside the lock).
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
4 ยท Diagnosis โ€” what the data showed
  • 48 of 64 thread samples (75 %) are in callPayment in state TIMED_WAITING (sleeping): the thread is parked inside a 30 ms downstream call.
  • 11 samples (17 %) were in validate RUNNABLE - 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.
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
5 ยท Optimisation โ€” what to change (and what not)

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.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
6 ยท Verification โ€” verify the model
CheckPredictionMeasured
Throughput ceiling, 8 threads, 31.5 ms/request8 / 0.0315 = 254 req/s254.1 and 250.2 req/s (both fixes), 254.6 in the JFR run
Share of the request that is waiting30.1 / 32 = 94 %ThreadSleep 30.1 ms of 31.4-32.0 ms wall
CPU utilisation of the 12 coreslow1.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.

async-profiler: the right tool for this (how-to - NOT run here)

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:

Shell - illustrative, from the async-profiler README; verify flags for your version
# CPU (default) ./asprof -e cpu -d 30 -f cpu.html <pid> # allocation (TLAB-sampled) ./asprof -e alloc -d 30 -f alloc.html <pid> # lock contention ./asprof -e lock -d 30 -f lock.html <pid> # wall clock (all threads, including sleeping and blocked ones) ./asprof -e wall -t -d 30 -f wall.html <pid>

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.

โš  Common bugs & edge cases:
  • 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.sleep in a polling loop). Search the wall-clock profile for sleep/park towers.
06
Chapter Six - Lab C: Allocation Profile

Garbage Is Not Free: Reading Allocation and GC Together

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
1 ยท Problem โ€” GC is running all the time

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.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
2 ยท Hypothesis โ€” what we expect

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.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
3 ยท Measurement โ€” measure
Shell
jfr view allocation-by-site rec.jfr jfr view gc-pauses rec.jfr
Output - jfr view allocation-by-site rec.jfr (baseline, real)
Allocation by Site Method Allocation Pressure ---------------------------------------------------------------------------------------- ------------------- jdk.internal.misc.Unsafe.allocateUninitializedArray(Class, int) 75.57% java.lang.Integer.valueOf(int) 10.90% java.util.Arrays.copyOf(Object[], int) 6.35% java.lang.StringConcatHelper.newString(byte[], long) 3.72% java.util.concurrent.ConcurrentHashMap.initTable() 1.34% java.lang.StringLatin1.newString(byte[], int, int) 1.28% jdk.internal.misc.Unsafe.allocateUninitializedArray0(Class, int) 0.44%
Allocation pressure by site, baseline (real JFR)
byte[] from String concat (render) 75.6 % Integer.valueOf (validate) 10.9 % Arrays.copyOf (ArrayList growth) 6.4 % StringConcatHelper.newString 3.7 % ConcurrentHashMap.initTable 1.3 % jfr view allocation-by-site rec.jfr (baseline). The leaf frames are JDK internals; the app frame under them is what you change.
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
4 ยท Diagnosis โ€” what the data showed
  • 75.6 % of allocation pressure is a byte[] allocated by string concatenation - the render() method: out += "line-" + i + ";" copies the whole string every iteration (quadratic bytes for 300 appends).
  • 10.9 % is Integer.valueOf - boxing in validate(); 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.
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
5 ยท Optimisation โ€” change one variable: StringBuilder
Java - fix 2 (-DfastRender=true)
StringBuilder sb = new StringBuilder(lines * 10); for (int i = 0; i < lines; i++) sb.append("line-").append(i).append(';'); return sb.toString();
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
6 ยท Verification โ€” same test, compare
MetricBaselineFix 1 onlyFix 1 + 2
Allocated per request454 KB531 KB154-155 KB
Young GCs in a 20 s JFR run17n/a4 (at 5,092 requests vs 3,405)
Throughput172-208 req/s244-247254 / 250 / 254.6
CPU per request5.9-7.2 ms1.2-1.3 ms1.2 ms
Allocation fix shows up in the GC log: 17 young collections to 4
baseline (3,405 req) 17 young GCs both fixes (5,092 req) 4 young GCs (+50 % more requests) jdk.GarbageCollection count from jfr summary. Baseline GC pause total 178 ms, median 3.2 ms, one 50 ms outlier (JFR gc-pauses view).

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.

โš  Common bugs & edge cases:
  • 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.ObjectAllocationSample weights and ensure enough samples.
  • Boxing in hot paths (Integer.valueOf, Long keys in maps, streams over boxed types) - a classic allocation source.
  • String concatenation in loops - quadratic copy; use StringBuilder/String.join/Collectors.joining.
07
Chapter Seven - Lab D: Lock Profile

Contention: Who Waits for Whom

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
1 ยท Problem โ€” time is lost between the lines

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.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
2 ยท Hypothesis โ€” what we expect

Hypothesis: the global AUDIT_LOCK serialises all eight workers for ~1 ms per request; with eight threads that is a queue.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
3 ยท Measurement โ€” measure
Shell
jfr view contention-by-site rec.jfr jfr print --events jdk.JavaMonitorEnter --stack-depth 6 rec.jfr
Output - jfr view contention-by-site (baseline, real)
Contention by Site StackTrace Count Avg. Max. --------------------------------------------------------- ----- ------- ------- jdk.jfr.internal.PlatformRecorder.periodicTask() 1 78.6 ms 78.6 ms SlowOrderService.audit(long) 105 18.1 ms 60.8 ms SlowOrderService.remember(long, String) 3 22.2 ms 31.6 ms java.lang.invoke.ClassSpecializer.findSpecies(Object) 1 10.0 ms 10.0 ms
Output - jcmd PID Thread.print: a BLOCKED worker (real, trimmed)
"http-worker-21" #21 [1515333] prio=5 os_prio=0 cpu=812.50ms elapsed=5.96s tid=0x00007879541b3990 nid=1515333 waiting for monitor entry [0x0000787924d2d000] java.lang.Thread.State: BLOCKED (on object monitor) at SlowOrderService.audit(SlowOrderService.java:52) - locked <0x0000000611c47ba8> (a java.lang.Object) at SlowOrderService.handleRequest(SlowOrderService.java:82) at SlowOrderService.timedRequest(SlowOrderService.java:72) at SlowOrderService.lambda$main$1(SlowOrderService.java:94) at SlowOrderService$$Lambda/0x00007878d8000c18.run(Unknown Source) at java.util.concurrent.Executors$RunnableAdapter.call(java.base@21.0.2/Executors.java:572) at java.util.concurrent.FutureTask.run(java.base@21.0.2/FutureTask.java:317) at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@21.0.2/ThreadPoolExecutor.java:1144)
Lock contention on audit(): threads queue behind the holder (schematic; waits measured)
worker-A hold 1ms worker-B BLOCKED hold worker-C BLOCKED hold worker-D BLOCKED hold A lock convoy in miniature: every thread passes one 1 ms critical section (schematic). Real numbers (baseline): 105 contended acquisitions, avg wait 18.1 ms, max 60.8 ms (jfr view contention-by-site).
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
4 ยท Diagnosis โ€” what the data showed
  • 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) at SlowOrderService.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.
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
5 ยท Optimisation โ€” what to change
TechniqueHowWhen
Shorten the critical sectionmove work out of the synchronized block; hold the lock only for the shared-state updatealways first
Reduce sharingper-thread buffers, LongAdder, sharded maps (ConcurrentHashMap)counters, caches
Lock striping / read-writeReentrantReadWriteLock, StampedLockread-mostly state
Hand off asynchronouslyqueue + single writer thread (audit log, metrics)logging / audit paths
Virtual threads and synchronizedon JDK 21 a virtual thread that blocks inside synchronized pins its carrier; fixed in JDK 24 (JEP 491)see Concurrency
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
6 ยท Verification โ€” verify

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.

โš  Common bugs & edge cases:
  • 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 in socketWrite/FileOutputStream.
  • Deadlock vs contention. jcmd Thread.print ends 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).
08
Chapter Eight - Lab E: Memory Growth

Class Histograms, Heap Dumps and the Leak

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
1 ยท Problem โ€” the heap keeps growing

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.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
2 ยท Hypothesis โ€” what we expect

Hypothesis: one application class's instance count rises monotonically between snapshots; a heap dump will show a single GC root path holding them.

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
3 ยท Measurement โ€” measure
Shell
jcmd <pid> GC.class_histogram | head -20 # snapshot 1 # ... wait 5 minutes of normal load ... jcmd <pid> GC.class_histogram | head -20 # snapshot 2: diff the instance counts jcmd <pid> GC.heap_dump /tmp/heap.hprof # full dump (stop-the-world; size ~ live heap)
Output - GC.class_histogram, 4 s and 10 s into the run (real, filtered)
num #instances #bytes class name (module) 1: 9937 809416 [B (java.base@21.0.2) 12: 736 23552 SlowOrderService$AuditEntry 110: 6 240 java.util.EnumMap (java.base@21.0.2) --- 6 s later --- num #instances #bytes class name (module) 1: 14601 2130120 [B (java.base@21.0.2) 5: 3065 98080 SlowOrderService$AuditEntry 110: 6 240 java.util.EnumMap (java.base@21.0.2)
Output - jcmd GC.heap_dump (real)
Dumping heap to .../heap.hprof ... Heap dump file created [6260711 bytes in 0.072 secs]
Leak signature: the same class's count only goes up
0 1,000 2,000 3,000 4,000 0 s 3 s 6 s 9 s 12 s t = 4 s: 736 AuditEntry t = 10 s: 3,065 AuditEntry instances of SlowOrderService$AuditEntry in the heap (two jcmd GC.class_histogram snapshots, line interpolated)
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
4 ยท Diagnosis โ€” what the data showed
  • SlowOrderService$AuditEntry grew from 736 to 3,065 instances in 6 seconds (rank 12 to rank 5 in the histogram) while byte[] 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 (an ArrayList) 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.
1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
5 ยท Optimisation โ€” what to change

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).

1 ยท Problem
2 ยท Hypothesis
3 ยท Measure
4 ยท Diagnose
5 ยท Optimise
6 ยท Verify
6 ยท Verification โ€” verify

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.

โš  Common bugs & edge cases:
  • GC.class_histogram and GC.heap_dump trigger a full GC first (unless -all is 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:HeapDumpPath on a volume that survives the container restart - otherwise the evidence vanishes with the pod.
09
Chapter Nine - Runbooks

Diagnostic Decision Flows

Memorise the order of questions, not the commands. Each flow ends with one change followed by a re-measure.

Runbook: "CPU is high"
CPU is high (top / container metric) confirm with USE: which cores, which process Mostly user time in the JVM? jcmd PID Thread.print x3 or top -H -p PID GC threads at the top? jfr view gc-pauses / -Xlog:gc => allocation profile System / iowait / steal high? vmstat, container throttling (not a Java profile problem) JFR CPU profile (60 s) jfr view hot-methods flame graph Allocation profile jfr view allocation-by-site reduce garbage, then GC flags Fix top frame (ONE change) re-record, compare samples yes yes
Runbook: "memory keeps growing"
Memory keeps growing heap used vs heap AFTER GC (jvm.memory.*) After-GC floor rising? yes = retention (leak / cache) no = just GC lag Heap fine, RSS grows? native: direct buffers, threads, metaspace (NMT) Container OOMKilled? limit < heap + non-heap (page 12) jcmd PID GC.class_histogram twice, 5 min apart; diff which class count rises? -XX:NativeMemoryTracking=summary jcmd PID VM.native_memory summary.diff jcmd PID GC.heap_dump open in MAT / JMC dominator tree, path to GC roots yes
Third runbook in words: "latency is high but CPU is low"
  • 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.
ToolBest forOverhead / riskUsed on this page
jcmd Thread.print / jstacka few snapshots: states, deadlocks, who holds a lockbrief safepoint pause; coarseyes - 8 dumps
JFRlow-overhead production CPU / alloc / lock / GC / I/O eventslow (default settings)yes - 2 recordings
jcmd GC.class_histogramwhat is growingforces GC by defaultyes
jcmd GC.heap_dump + MAT / JMCwho retains it (dominator tree, GC roots)long pause on big heaps; sensitive datadump taken; MAT not run
async-profileraccurate CPU / alloc / lock / wall flame graphs, native framesneeds binary and perf permissionsno - not available here
Micrometer / Actuatorcontinuous metrics: p99, GC pause, pool pendingnegligiblepage 13 / Spring Actuator
โš  Common bugs & edge cases:
  • 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.
10
Chapter Ten - Build It

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.)

The commands used
Shell
javac SlowOrderService.java # baseline vs fixes (throughput + per-request cpu/alloc printed at the end) java SlowOrderService 8 15 java -DfastValidate=true SlowOrderService 8 15 java -DfastValidate=true -DfastRender=true SlowOrderService 8 15 # record 20 s with JFR, then analyse java -XX:StartFlightRecording=filename=rec.jfr,settings=profile SlowOrderService 8 20 jfr summary rec.jfr jfr view hot-methods rec.jfr jfr view allocation-by-site rec.jfr jfr view contention-by-site rec.jfr jfr view gc-pauses rec.jfr # while it runs (second terminal) jcmd SlowOrderService Thread.print jcmd SlowOrderService GC.class_histogram jcmd SlowOrderService GC.heap_dump /tmp/heap.hprof
Results of the throughput runs (real; host load average 15-20 on 12 cores)
Output - per-variant runs
== [-Dx=0] run 1 completed 3125 requests = 208.3 req/s, leaked entries: 3125 per request: wall 38.4 ms, cpu 5.9 ms, allocated 454 KB == [-Dx=0] run 2 completed 2583 requests = 172.2 req/s, leaked entries: 2583 per request: wall 46.3 ms, cpu 7.2 ms, allocated 454 KB == [-DfastValidate=true] run 1 completed 3665 requests = 244.3 req/s, leaked entries: 3665 per request: wall 32.6 ms, cpu 1.3 ms, allocated 531 KB == [-DfastValidate=true] run 2 completed 3706 requests = 247.1 req/s, leaked entries: 3706 per request: wall 32.4 ms, cpu 1.2 ms, allocated 531 KB == [-DfastValidate=true -DfastRender=true] run 1 completed 2795 requests = 186.3 req/s, leaked entries: 2795 per request: wall 42.9 ms, cpu 6.8 ms, allocated 454 KB == [-DfastValidate=true -DfastRender=true] run 2 completed 3363 requests = 224.2 req/s, leaked entries: 3363 per request: wall 35.7 ms, cpu 5.3 ms, allocated 454 KB == [fastValidate+fastRender] run 1 completed 3811 requests = 254.1 req/s, leaked entries: 3811 per request: wall 31.5 ms, cpu 1.2 ms, allocated 154 KB == [fastValidate+fastRender] run 2 completed 3753 requests = 250.2 req/s, leaked entries: 3753 per request: wall 32.0 ms, cpu 1.2 ms, allocated 154 KB
SlowOrderService.java (complete)
Java - SlowOrderService.java
import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.*; /** A deliberately slow stand-in for one OrderService request, with four distinct problems: * 1. validate() - CPU: O(n^2) duplicate check * 2. render() - allocation: string concatenation in a loop * 3. audit() - lock contention: one global lock held for ~1 ms * 4. callPayment() - waiting: a 30 ms blocking downstream call (no CPU) * 5. remember() - leak: every request adds an object to a static list */ public class SlowOrderService { record AuditEntry(long id, String text, byte[] payload) { } static final Object AUDIT_LOCK = new Object(); static final List<AuditEntry> LEAK = new ArrayList<>(); static final AtomicLong IDS = new AtomicLong(); static final LongAdder DONE = new LongAdder(); static final boolean FAST_VALIDATE = Boolean.getBoolean("fastValidate"); static final boolean FAST_RENDER = Boolean.getBoolean("fastRender"); static int validate(int n) { if (FAST_VALIDATE) { // fix 1: HashSet makes the duplicate check O(1) per element Set<Integer> seenFast = new HashSet<>(); int dups = 0; for (int i = 0; i < n; i++) if (!seenFast.add((i * 31) % (n * 2))) dups++; return dups; } List<Integer> seen = new ArrayList<>(); int dups = 0; for (int i = 0; i < n; i++) { Integer v = (i * 31) % (n * 2); if (seen.contains(v)) dups++; // O(n) scan inside an O(n) loop seen.add(v); } return dups; } static String render(int lines) { if (FAST_RENDER) { // fix 2: one StringBuilder instead of 300 intermediate Strings StringBuilder sb = new StringBuilder(lines * 10); for (int i = 0; i < lines; i++) sb.append("line-").append(i).append(';'); return sb.toString(); } String out = ""; for (int i = 0; i < lines; i++) out += "line-" + i + ";"; // new String every iteration return out; } static void audit(long id) { synchronized (AUDIT_LOCK) { long end = System.nanoTime() + 1_000_000; // hold the lock ~1 ms while (System.nanoTime() < end) Thread.onSpinWait(); } } static void callPayment() { try { Thread.sleep(30); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } static void remember(long id, String text) { synchronized (LEAK) { LEAK.add(new AuditEntry(id, text, new byte[512])); } } static final com.sun.management.ThreadMXBean MX = (com.sun.management.ThreadMXBean) java.lang.management.ManagementFactory.getThreadMXBean(); static final LongAdder CPU_NS = new LongAdder(), ALLOC_BYTES = new LongAdder(), WALL_NS = new LongAdder(); static void timedRequest() { long tid = Thread.currentThread().threadId(); long cpu0 = MX.getThreadCpuTime(tid), alloc0 = MX.getThreadAllocatedBytes(tid), wall0 = System.nanoTime(); handleRequest(); CPU_NS.add(MX.getThreadCpuTime(tid) - cpu0); ALLOC_BYTES.add(MX.getThreadAllocatedBytes(tid) - alloc0); WALL_NS.add(System.nanoTime() - wall0); } static void handleRequest() { long id = IDS.incrementAndGet(); validate(2500); String body = render(300); audit(id); callPayment(); remember(id, body.substring(0, 20)); DONE.increment(); } public static void main(String[] args) throws Exception { int threads = Integer.parseInt(args.length > 0 ? args[0] : "8"); int seconds = Integer.parseInt(args.length > 1 ? args[1] : "20"); System.out.println("pid=" + ProcessHandle.current().pid() + " threads=" + threads + " seconds=" + seconds); long end = System.nanoTime() + seconds * 1_000_000_000L; ExecutorService pool = Executors.newFixedThreadPool(threads, r -> { Thread t = new Thread(r); t.setName("http-worker-" + t.threadId()); return t; }); for (int i = 0; i < threads; i++) pool.submit(() -> { while (System.nanoTime() < end) timedRequest(); }); pool.shutdown(); pool.awaitTermination(seconds + 30, TimeUnit.SECONDS); long n = DONE.sum(); System.out.printf("completed %d requests = %.1f req/s, leaked entries: %d%n", n, n / (double) seconds, LEAK.size()); System.out.printf("per request: wall %.1f ms, cpu %.1f ms, allocated %.0f KB%n", WALL_NS.sum() / 1e6 / n, CPU_NS.sum() / 1e6 / n, ALLOC_BYTES.sum() / 1024.0 / n); } }
Experiments to try
  • Set threads to 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:gc to a baseline run and match the GC lines to the allocation profile.
  • Write a script that turns jfr print --json output into folded stacks and feed it to a flame-graph generator; the SVGs on this page were produced this way.
11
Chapter Eleven - Interview Angles

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 with jfr or 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.
12
Chapter Twelve - Summary

Cheat Sheet and Recap

Symptom to profile
SymptomFirst profileThen
High CPU, throughput cappedCPU flame graph (JFR / async-profiler cpu)fix the widest top frame; re-record
High GC time / frequent GCallocation profile + GC logreduce allocation rate; tune the collector (page 3)
Latency high, CPU lowthread dumps / wall-clock, tracesfind the wait: lock, pool, I/O, dependency
p99 spikes, p50 finelock profile + GC pausescontention or pauses at the tail
Heap or RSS climbingclass histogram diff, heap dump; NMT for nativebound or release the retained objects
Throughput flat as threads growlock profile; USE on poolsshared resource (page 1 scaling laws)
โˆ‘ Profiling Toolbox - what to remember
  • 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.
Related pages: