Performance Profiling
Stop guessing where your Java program is slow. Learn to measure it — with JMH benchmarks, JFR and async-profiler recordings, and flame graphs — then fix the one hotspot that actually matters.
Learn Performance Profiling in our free Java course — a beginner-friendly interactive lesson with worked examples, a practice exercise and a quick reference.
Part of the free Java course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
Before You Start
This lesson builds on Memory Management & GC and JVM Internals — you'll want to know what a heap and a garbage collector are. Familiarity with Thread Pools and Java collections helps too. The previous lesson covered concurrency; this one covers finding the bottlenecks in it.
A Real-World Analogy: The Detective, Not the Psychic
💡 Analogy: A good detective does not guess who committed the crime and arrest the first person they suspect. They gather evidence, follow the trail, and let the facts name the culprit. A profiler is your forensic kit: it collects the evidence (where time and memory actually go) so you stop arresting innocent code.
The golden rule of performance work is three words: measure, don't guess. Developers are famously bad at predicting where a program spends its time — the slow part is almost never where intuition points. The cost of guessing is real: you rewrite a clever method that was never the problem, add complexity and bugs, and the program is no faster.
There's a reason this works. The 90/10 rule : roughly 90% of execution time is spent in about 10% of the code. Your entire job is to find that 10% — the hotspot — and leave the other 90% of the code clean and readable. A flame graph shows you the hotspot in one glance.
1️⃣ The Optimisation Loop (and the Toolbox)
Every performance fix follows the same disciplined loop. Skip a step — especially jumping straight to "fix" — and you waste effort optimising code that was never slow.
Measure (is it even slow, and by how much?) → Profile (record CPU + allocations) → Identify (read the flame graph for the widest bar) → Fix (change only the hotspot) → Verify (re-measure to confirm the win is real).
Each tool below fits a different step. You don't need all of them at once — pick by what you're asking:
Tool
What it is
Overhead
Best for
JFR
Built-in flight recorder
<1%
Always-on production profiling
async-profiler
Sampling profiler
~2%
CPU + allocation flame graphs
JMC / VisualVM
GUI viewers
5–15%
Exploring a recording, dev debugging
JMH
Microbenchmark harness
N/A
Comparing two implementations
jcmd / jfr
CLI diagnostics
Minimal
Thread/heap dumps, reading .jfr
2️⃣ Microbenchmarking with JMH (and Its Pitfalls)
A microbenchmark measures one tiny piece of code in isolation — like "is + or StringBuilder faster?". You cannot do this reliably with a hand-written timing loop, because the JVM cheats you in two ways.
Warmup: for the first thousands of calls your code runs in the slow interpreter ; only once the JIT compiler (Just-In-Time — it compiles hot bytecode to native code on the fly) has warmed up do you see real speed. Time too early and every result is wrong.
Dead-code elimination: if the JIT can prove a result is never used, it deletes the entire computation . Your benchmark then times nothing and reports an impossibly fast number.
JMH (Java Microbenchmark Harness, from the JDK team) handles both: it warms up first, runs the timed iterations after, forks fresh JVMs for isolation, and forces you to return the result or feed it to a Blackhole so the optimiser can't delete it.
🎯 Your Turn #1 — Finish the JMH Benchmark
Fill in three blanks: set warmup to 3 iterations, measurement to 5, and return the sum so the JIT can't delete the loop as dead code. The expected output shape is in the comment.
3️⃣ CPU & Allocation Profiling: JFR and async-profiler
A benchmark compares two known options. A profiler answers the bigger question: in a whole running app, where is the time and memory going? You attach it, let real traffic flow, and it samples what's happening.
Java Flight Recorder (JFR) is built into the JDK and costs under 1% overhead, so you can leave it on in production. It records a .jfr file of CPU samples, allocations, GC events and more, which you read with the jfr CLI or open in JDK Mission Control (JMC) .
async-profiler is a separate, low-overhead sampling profiler that produces interactive flame graphs for both CPU (where time goes) and allocations (what creates garbage). Two recordings, two different questions — always check allocation too, because excessive object creation is one of the most common hidden slowdowns.
4️⃣ Reading a Flame Graph
A flame graph turns thousands of sampled call stacks into one picture. Learn to read it and you can find a hotspot in seconds.
- Y axis = stack depth. Callers at the bottom, the methods they call stacked on top.
- X axis is NOT time. Frame width = share of samples — how much CPU (or allocation) that method accounts for.
- Wide, flat bars = your hotspots. Optimise the widest frame first.
- Tall, thin towers are deep call chains that individually cost little — usually not worth touching.
💡 The maths of effort: a method that is 2% of the width can never give you more than a 2% speed-up, no matter how cleverly you rewrite it. A 60%-wide method is where the real win is. Width tells you where to spend your time — that's the whole point.
🎯 Your Turn #2 — Record & Read a Flame Graph
Fill in the blanks to record a 20-second CPU flame graph, then answer the self-check question about which method to optimise first.
5️⃣ Common Java Performance Traps
Profilers keep surfacing the same handful of culprits in Java code. Knowing them lets you read a flame graph and immediately recognise the pattern.
- Autoboxing in hot loops: mixing primitives with wrapper types ( Integer , Long ) silently allocates an object per operation — millions of them in a loop, which crushes the GC.
- String concatenation with + in a loop: each + builds a brand-new String , turning O(n) work into O(n²). Use a StringBuilder .
- Recompiling regexes / reallocating in a loop: Pattern.compile(...) or new -ing throwaway objects inside the hot path repeats expensive work every iteration.
- Premature optimisation: contorting code for speed before profiling proves it matters — the opposite trap.
The runnable example below shows the autoboxing trap with a deterministic fix: a boxed Long accumulator versus a primitive long . Same answer, very different speed.
6️⃣ GC Tuning Basics
The garbage collector (GC) reclaims objects you no longer reference. Most of the time it just works — and the single biggest GC win is allocating less in the first place. Only reach for tuning when a recording shows real GC pain: long pauses, or GC eating a big slice of CPU.
When you do tune, start by matching the collector to your goal, then size the heap, then change one flag at a time and re-measure with a GC log.
Collector
Flag
Optimised for
G1 (default)
-XX:+UseG1GC
Balanced pause vs throughput
ZGC
-XX:+UseZGC
Very low pauses, large heaps
Shenandoah
-XX:+UseShenandoahGC
Low pauses, concurrent
Parallel
-XX:+UseParallelGC
Raw throughput, batch jobs
Turn on a GC log first — -Xlog:gc*:file=gc.log:time,uptime — and read it before changing anything. Copying a wall of -XX: flags from a random blog post usually makes things worse , not better.
Mini-Challenge — Prove the String Trap
Scaffolding removed. Read the comment outline, then write it yourself: build a big string two ways ( += in a loop vs a StringBuilder ), time both, and print the slow/fast ratio. The expected shape is in the comment so you can self-check.
Common Errors (and the fix)
- ❌ Micro-benchmarking without JMH: a System.nanoTime() loop measures the interpreter before warmup, then gets its loop deleted by dead-code elimination after. Fix: use JMH — it warms up, forks JVMs, and forces you to return the result so the optimiser can't cheat.
- ❌ Optimising the wrong thing: "I'm sure this method is slow" is a guess, not evidence; the real hotspot is almost always elsewhere. Fix: profile first, find the widest frame in the flame graph, and only optimise that.
- ❌ Ignoring GC / allocation: a CPU profile looks fine but the app stutters because it allocates millions of short-lived objects and the GC runs constantly. Fix: take an allocation profile (async-profiler -e alloc or JFR allocation events) and cut the biggest allocators.
- ❌ Autoboxing in a hot loop: Long total = 0L; total += v; re-boxes a new Long every iteration. Fix: keep accumulators primitive ( long total = 0L; ) and prefer IntStream / LongStream over Stream<Integer> .
- ❌ Heap dump fills the disk: jcmd PID GC.heap_dump writes a file as big as your heap (could be many GB) and can crash the box if disk is short. Fix: check free space first, and prefer -XX:+HeapDumpOnOutOfMemoryError with a dedicated -XX:HeapDumpPath .
- ❌ Premature optimisation: rewriting clean code into something clever and buggy before profiling proves it's hot. Fix: make it work, make it right, then — guided by a profiler — make the measured hotspot fast.
📋 Quick Reference
Goal
Command / API
Notes
Start a recording
-XX:StartFlightRecording=...
Writes a .jfr file
Attach to a live JVM
jcmd PID JFR.start ...
Find PID with jps
Read a recording
jfr summary app.jfr
Or open in JMC
CPU flame graph
asprof -d 30 -f cpu.html PID
Allocation flame graph
asprof -e alloc -f a.html PID
Finds garbage sources
Benchmark two options
@Benchmark (JMH)
Return the result!
Heap dump
jcmd PID GC.heap_dump f.hprof
As big as the heap
Thread dump
jcmd PID Thread.print
Thread states
GC log
-Xlog:gc*:file=gc.log
Read before tuning
Dump on OOM
-XX:+HeapDumpOnOutOfMemoryError
Set HeapDumpPath
Frequently Asked Questions
🎉 Lesson Complete!
Excellent work! You can now measure instead of guess : write correct JMH benchmarks, record CPU and allocation profiles with JFR and async-profiler, read a flame graph to find the real hotspot, tune the GC only when the evidence calls for it, and recognise the classic Java traps — autoboxing, string concatenation in loops, and premature optimisation.
Next up: Microservices — building distributed systems with Spring Boot, where these profiling skills scale up to whole fleets of services.
Practice quiz
What is the golden rule of performance work?
- Optimise everything up front
- Always tune the garbage collector first
- Measure, don't guess
- Rewrite in a faster language
Answer: Measure, don't guess. Developers are bad at predicting hotspots. Profile to find where time actually goes, then fix the measured bottleneck.
Why is a hand-rolled System.nanoTime() loop unreliable for microbenchmarks?
- It ignores JIT warmup and the JIT can delete unused results (dead-code elimination)
- nanoTime is inaccurate by design
- It only works on one core
- It cannot measure milliseconds
Answer: It ignores JIT warmup and the JIT can delete unused results (dead-code elimination). Before JIT warmup code runs in the slow interpreter; after, the JIT may delete a result that's never used. JMH handles both.
In JMH, why must a @Benchmark return its result or use a Blackhole?
- To print the result
- To make it run faster
- It is optional
- So the JIT can't delete the computation as dead code
Answer: So the JIT can't delete the computation as dead code. Returning the value (or feeding a Blackhole) tells JMH the result is used, defeating dead-code elimination.
What is the purpose of JMH's warmup iterations?
- To heat up the CPU
- To let the JIT compile hot code before the timed measurements begin
- To allocate the heap
- To run the test twice for averaging only
Answer: To let the JIT compile hot code before the timed measurements begin. Warmup runs let the JIT compile the hot bytecode to native code, so the measured iterations reflect steady-state speed.
On a flame graph, what does the WIDTH of a frame represent?
- The share of samples (CPU or allocation) that method accounts for
- Time order of execution
- Stack depth
- The number of threads
Answer: The share of samples (CPU or allocation) that method accounts for. The X axis is not time — width is the share of samples. The widest frame is your hotspot, so optimise it first.
Which built-in JDK tool has under ~1% overhead and is suitable for always-on production profiling?
- JMH
- VisualVM
- Java Flight Recorder (JFR)
- jdb
Answer: Java Flight Recorder (JFR). JFR is built into the JDK with <1% overhead, recording CPU samples, allocations, and GC events to a .jfr file.
What question does ALLOCATION profiling answer (vs CPU profiling)?
- Where is my time going?
- What is creating garbage / which call sites allocate the most?
- Which threads are blocked?
- How big is the heap?
Answer: What is creating garbage / which call sites allocate the most?. Allocation profiling samples object allocations to find garbage sources; CPU profiling finds where time is spent.
Why is autoboxing in a hot loop expensive?
- It throws exceptions
- It uses more CPU registers
- It disables the JIT
- Each box is a heap allocation, creating millions of throwaway objects that hammer the GC
Answer: Each box is a heap allocation, creating millions of throwaway objects that hammer the GC. Wrapping primitives into Integer/Long/Double allocates on the heap. A boxed accumulator re-boxes every iteration.
What is the single biggest garbage-collection win in most apps?
- Copying GC flags from a blog
- Allocating less in the first place
- Always switching to ZGC
- Increasing -Xmx to the maximum
Answer: Allocating less in the first place. If allocation is low, GC mostly takes care of itself. Tune the collector only when a recording shows real GC pain.
Why is 'String s = ""; s += x;' inside a loop a performance trap?
- It throws an exception
- It never compiles
- Each '+' builds a brand-new String, turning O(n) work into O(n^2)
- Strings are immutable so it is actually fast
Answer: Each '+' builds a brand-new String, turning O(n) work into O(n^2). Each concatenation copies the whole string, making it O(n^2). Use a StringBuilder for O(n) appends.
Continue this course
- Previous: Previous Lesson
- Next: Next Lesson