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.
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.
💡 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.
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:
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.
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.
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.
A flame graph turns thousands of sampled call stacks into one picture. Learn to read it and you can find a hotspot in seconds.
💡 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.
Fill in the blanks to record a 20-second CPU flame graph, then answer the self-check question about which method to optimise first.
Profilers keep surfacing the same handful of culprits in Java code. Knowing them lets you read a flame graph and immediately recognise the pattern.
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.
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.
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.
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.
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.