Java developers eventually encounter a familiar problem.
The application works.
The code looks reasonable.
The tests pass.
But something is slow.
Maybe an API takes 2 seconds instead of 200 milliseconds.
Maybe CPU usage suddenly reaches 100%.
Maybe memory keeps growing.
Maybe garbage collection starts happening frequently.
Maybe requests are waiting even though CPU usage is low.
Or perhaps the application is fast after startup but becomes slower under real traffic.
The first instinct is often to start changing code.
Maybe the loop is inefficient.
Maybe HashMap is slow.
Maybe too many objects are being created.
Maybe garbage collection needs more memory.
Maybe another thread pool is needed.
Sometimes those guesses are correct.
Most of the time, however, we don't know yet.
This is where performance profiling becomes important.
Performance profiling is the process of observing an application while it runs and finding out where it actually spends its time and resources.
The important word is:
Actually
Instead of asking:
"What code looks slow?"
we ask:
"Where is the application actually spending its resources?"
That difference changes the entire approach to performance optimization.
What Is Performance Profiling?
Performance profiling means collecting information about a running application so that we can understand its behavior.
For a Java application, we may want to know:
CPU
│
├── Which methods consume CPU?
│
Memory
│
├── Which objects are being allocated?
│
GC
│
├── How frequently is garbage collection happening?
│
Threads
│
├── Which threads are running?
│
├── Which threads are waiting?
│
└── Which threads are blocked?
│
Locks
│
└── Where is contention happening?
│
I/O
│
├── Disk
│
└── Network
│
Latency
│
└── Where is request time being spent?
The profiler gives us evidence.
We then use that evidence to form a hypothesis.
So performance work should usually look like:
Observe
↓
Measure
↓
Find bottleneck
↓
Form hypothesis
↓
Change code/configuration
↓
Measure again
↓
Verify improvement
This is much safer than:
Guess
↓
Change code
↓
Hope
Why Profiling Is Necessary
Consider this method:
public void processOrder(Order order) {
validate(order);
calculatePrice(order);
save(order);
sendNotification(order);
}
Suppose the entire method takes:
500 ms
We might assume calculatePrice() is expensive.
But profiling could reveal:
validate() 5 ms
calculatePrice() 10 ms
save() 450 ms
sendNotification() 35 ms
Now the situation is completely different.
The Java code that performs the calculation isn't the problem.
The database operation is.
If we spend a day optimizing:
calculatePrice()
we might reduce:
10 ms → 5 ms
The request becomes:
500 ms → 495 ms
Almost no meaningful improvement.
If we optimize the database operation:
450 ms → 80 ms
the request becomes:
500 ms → 130 ms
That is a real performance improvement.
This is why one of the most important rules of performance engineering is:
Measure before optimizing.
What Can Make a Java Application Slow?
A Java application can be slow for many different reasons.
It isn't necessarily because Java code is executing slowly.
A request might look like this:
HTTP Request
↓
Controller
↓
Service
↓
Repository
↓
Connection Pool
↓
Database
But the application might actually spend its time here:
Controller 2 ms
Service 5 ms
Repository 3 ms
Database 480 ms
The JVM isn't the bottleneck.
The database is.
Another application might look like:
HTTP Request
↓
Controller
↓
Service
↓
JSON serialization
and profiling might show:
Business logic 20 ms
JSON serialization 300 ms
Another might show:
Application code 50 ms
GC 80 ms
Lock contention 200 ms
Database 20 ms
The point is:
"Slow Java application"
doesn't identify the actual problem.
Profiling helps us discover it.
CPU Profiling
Let's start with CPU.
Suppose your application is using:
CPU = 100%
We need to know:
What is consuming the CPU?
Consider:
public void process(List<Order> orders) {
for (Order order : orders) {
calculatePrice(order);
validate(order);
save(order);
}
}
A CPU profiler might produce something like:
calculatePrice() 42%
validate() 18%
save() 8%
JSON serialization 7%
Other 25%
Now we have useful information.
calculatePrice() is consuming a large portion of the CPU samples.
That becomes a candidate for investigation.
But notice something important.
The profiler didn't say:
"calculatePrice() is badly written."
It said:
"calculatePrice() consumes a significant amount of CPU."
Those are different statements.
The method may be perfectly reasonable.
Perhaps it is simply called millions of times.
For example:
for (Order order : orders) {
calculatePrice(order);
}
The problem might not be the method itself.
The problem might be its call frequency.
CPU Time Is Not the Same as Request Time
This is one of the most important concepts in performance profiling.
Suppose:
public void process() {
callDatabase();
}
The database takes:
500 ms
But the Java thread spends most of that time waiting.
The CPU might be doing almost nothing.
Therefore:
CPU profile
might not show callDatabase() as an expensive CPU operation.
Yet the user experiences:
500 ms latency
This gives us two different questions.
CPU profiling
Where is CPU time being consumed?
Wall-clock profiling
Where is elapsed time being spent?
These are not the same thing.
A thread can spend a large amount of time:
waiting for database
waiting for network
waiting for a lock
sleeping
without consuming much CPU.
Sampling Profilers
One common way to profile CPU is through sampling.
Imagine a profiler checking a thread every few milliseconds.
For example:
t = 0 ms calculatePrice()
t = 10 ms calculatePrice()
t = 20 ms calculatePrice()
t = 30 ms validate()
t = 40 ms calculatePrice()
t = 50 ms calculatePrice()
After collecting many samples:
calculatePrice() 80%
validate() 15%
other 5%
The profiler isn't necessarily recording every method call.
It periodically samples execution and aggregates the results.
This generally makes sampling useful for understanding where execution time is concentrated without requiring every method to be instrumented.
Instrumentation
Another approach is instrumentation.
Conceptually, the profiler adds measurement around methods:
long start = System.nanoTime();
method();
long duration = System.nanoTime() - start;
This can provide detailed information about method execution.
But instrumentation can also introduce overhead.
For broad performance investigations, sampling is often an excellent starting point.
For targeted investigations, instrumentation can sometimes provide more detailed information.
Java Flight Recorder
One of the most important tools every Java developer should know is:
Java Flight Recorder
or:
JFR
JFR is a profiling and event-collection framework built into the JDK.
It records runtime events from the JVM and application.
These events can include information about:
CPU
Garbage Collection
Threads
Locks
Exceptions
Class loading
JIT compilation
Memory
I/O
Safepoints
JFR is designed to collect detailed runtime information with low overhead, making it particularly useful for diagnosing applications, including production systems.
A useful mental model is:
Java Application
↓
Java Flight Recorder
↓
recording
↓
app.jfr
↓
JDK Mission Control
↓
Analysis
Starting a Flight Recording
Suppose your application is running with PID:
12345
We can start a recording with:
jcmd 12345 JFR.start
We can check the recording:
jcmd 12345 JFR.check
And eventually stop it:
jcmd 12345 JFR.stop filename=recording.jfr
The .jfr file can then be opened using JDK Mission Control.
JDK Mission Control provides tools for analyzing JFR data, including code performance, memory, latency, threads and other runtime behavior.
Why JFR Is So Useful
Imagine an API suddenly becomes slow in production.
You don't necessarily want to attach a heavy profiler and restart the application.
JFR allows you to collect runtime information and analyze the recording afterward.
Conceptually:
Production Application
│
│ JFR
▼
runtime events
│
▼
recording.jfr
│
▼
JDK Mission Control
│
├── CPU
├── Memory
├── GC
├── Threads
├── Locks
└── Latency
This is especially powerful for problems that are difficult to reproduce.
JDK Mission Control
JFR produces the data.
JDK Mission Control helps us understand it.
A useful way to think about them is:
JFR
↓
Collect information
JMC
↓
Understand information
When you open a recording, you can investigate different areas.
For example:
Code
├── Hot methods
└── Execution samples
Memory
├── Allocations
└── Heap behavior
Garbage Collection
├── GC pauses
└── Collection activity
Threads
├── Running
├── Waiting
└── Blocked
Locks
└── Contention
I/O
└── File and network activity
The official JDK Mission Control documentation describes JFR and JMC as a toolchain for collecting and analyzing low-level JVM and application runtime information.
Finding CPU Hotspots
One of the first things to look at in a CPU problem is the hottest code.
Imagine the profiler reports:
CPU samples
OrderService.process() 5%
PricingService.calculate() 45%
JsonSerializer.serialize() 20%
HashMap.get() 10%
Other 20%
The natural candidate is:
PricingService.calculate()
But don't immediately rewrite it.
Ask:
Why is this method hot?
There are several possibilities.
Possibility 1: The method is inherently expensive
calculateComplexPrice();
Possibility 2: It is called too frequently
for (...) {
calculatePrice();
}
Possibility 3: It performs unnecessary work
calculatePrice();
calculatePrice();
calculatePrice();
Possibility 4: A downstream operation is expensive
calculatePrice()
↓
BigDecimal operations
↓
large computation
Profiling tells us where to look.
We still need to understand why.
Flame Graphs
A very useful visualization for profiling is the:
Flame Graph
Imagine a stack like:
main()
└── processRequest()
└── calculatePrice()
└── calculateTax()
└── BigDecimal.multiply()
A flame graph aggregates these stacks.
A simplified representation might look like:
+--------------------------------------------------------+
| processRequest() |
+-------------------------------+------------------------+
| calculatePrice() | other() |
+-------------------+-----------+------------------------+
| calculateTax() | other | |
+-------------------+-----------+ |
| BigDecimal | | |
+-------------------+ +------------------------+
The width of a stack represents the amount of sampled activity associated with that stack.
A wide area is therefore worth investigating.
But a flame graph does not mean:
"This method took exactly this many milliseconds."
It represents aggregated profiling samples.
That distinction is important.
async-profiler
Another powerful tool in the Java ecosystem is:
async-profiler
It is a low-overhead sampling profiler for Java and can profile Java and native execution. It can collect CPU samples, allocations, locks, native memory and other events.
A simple CPU profiling command is:
asprof -d 30 -f flamegraph.html <PID>
This profiles the process for 30 seconds and produces a flame graph.
For example:
asprof -d 30 -f cpu.html 12345
You can then open:
cpu.html
in a browser.
Why async-profiler Is Interesting
Traditional profiling approaches can sometimes miss important execution because of JVM safepoints or because the interesting work happens outside ordinary Java methods.
async-profiler can capture:
Java code
JVM code
Native code
Kernel frames
This can make it particularly useful when the performance problem crosses the boundary between Java and the operating system.
For example:
Java
↓
JNI
↓
Native library
↓
Kernel
A Java-only view might not tell the entire story.
Wall-Clock Profiling
Suppose you have:
Request latency = 1 second
but:
CPU usage = low
Where did the second go?
Possibly:
Database wait
Network wait
Lock wait
Thread parking
I/O
This is where wall-clock profiling becomes useful.
A wall-clock profiler samples threads based on elapsed time rather than only CPU execution.
Conceptually:
CPU profiling
Running ──── Running ──── Running
Wall-clock profiling
Running ─── Waiting ─── Blocked ─── Running
async-profiler supports wall-clock profiling and can include threads regardless of whether they are running, sleeping or blocked.
Thread Profiling
Java applications are usually highly concurrent.
Therefore, performance problems aren't always about individual methods.
Sometimes the problem is:
Thread contention
Imagine:
public synchronized void process() {
performExpensiveOperation();
}
Suppose 50 threads call it.
Only one can enter the synchronized method at a time.
The situation could look like:
Thread 1 → RUNNING
Thread 2 → BLOCKED
Thread 3 → BLOCKED
Thread 4 → BLOCKED
Thread 5 → BLOCKED
...
CPU usage may not be particularly high.
But request latency can be terrible.
The problem isn't CPU.
It is contention.
Thread Dumps
A thread dump gives us a snapshot of what threads are doing.
With modern JDKs, jcmd provides useful diagnostic commands.
For example:
jcmd <PID> Thread.print
You may see states such as:
RUNNABLE
BLOCKED
WAITING
TIMED_WAITING
A simplified example:
Thread-1 RUNNABLE
Thread-2 BLOCKED
waiting for lock
Thread-3 WAITING
waiting for condition
Thread-4 TIMED_WAITING
sleeping
If hundreds of threads are blocked on the same lock, you've found an important clue.
Lock Contention
Consider:
private final Object lock = new Object();
public void process() {
synchronized (lock) {
expensiveOperation();
}
}
If:
expensiveOperation()
takes 500 milliseconds, every other thread attempting to acquire the same lock may wait.
The performance problem becomes:
Thread A
↓
acquire lock
↓
500 ms operation
↓
release lock
Thread B
↓
wait 500 ms
Thread C
↓
wait 500 ms
Thread D
↓
wait 500 ms
Profiling lock contention can reveal this immediately.
async-profiler supports lock profiling for measuring contention and time spent waiting to acquire Java locks.
Memory Profiling
Not every performance problem is CPU-related.
Consider:
for (int i = 0; i < 10_000_000; i++) {
String value = new String("hello");
}
The important question isn't simply:
"How much memory is being used?"
We should also ask:
"How much memory is being allocated?"
These are different things.
An application could have:
Heap usage = 2 GB
while allocating:
10 GB / second
Most of those objects may die quickly.
Another application could have:
Heap usage = 10 GB
but a very low allocation rate.
These applications have completely different performance characteristics.
Allocation Rate
Imagine:
Allocation rate:
500 MB/s
1 GB/s
2 GB/s
4 GB/s
High allocation rates can create substantial garbage collection pressure.
For example:
Requests
↓
Create objects
↓
Objects become unreachable
↓
GC must reclaim them
↓
More allocations
↓
More GC work
This doesn't mean:
"Object allocation is bad."
Modern JVMs are very good at allocating short-lived objects.
The real question is:
Is allocation pressure causing meaningful CPU consumption, GC activity, or latency?
Garbage Collection Profiling
Suppose an application has:
CPU 60%
GC CPU 25%
Request latency 800 ms
We should investigate GC.
A simplified lifecycle looks like:
new objects
↓
Young generation
↓
GC
↓
short-lived objects reclaimed
↓
surviving objects
↓
older generations / regions
Different garbage collectors make different trade-offs.
Modern HotSpot includes collectors such as:
Serial
Parallel
G1
ZGC
Shenandoah
Profiling tells us what is actually happening rather than assuming that the collector is the problem.
Don't Immediately Increase the Heap
Suppose you see:
-Xmx8g
and:
Heap usage = 90%
The obvious reaction might be:
-Xmx16g
But this may not solve the real problem.
Suppose the application has a memory leak:
Objects continuously retained
↓
Heap grows
↓
GC becomes more expensive
↓
Heap approaches maximum
Doubling the heap may simply delay the failure.
The better approach is:
Find allocation
↓
Find retained objects
↓
Find GC roots
↓
Find why objects remain reachable
↓
Fix retention
Heap Dumps
When investigating memory leaks, a heap dump can be extremely useful.
For example:
jcmd <PID> GC.heap_dump /tmp/heap.hprof
You can analyze the resulting heap dump using tools such as Eclipse Memory Analyzer.
The important question is:
"What objects are consuming memory?"
But an even more important question is:
"Why are these objects still reachable?"
For example:
GC Root
↓
static cache
↓
HashMap
↓
User objects
↓
10 GB
The problem isn't necessarily the User objects.
The problem is the cache retaining them.
jcmd
If you only learn one JVM diagnostic command, make it:
jcmd
It provides many useful JVM diagnostic operations.
For example:
jcmd <PID> VM.version
Show JVM version.
jcmd <PID> VM.flags
Show JVM flags.
jcmd <PID> GC.heap_info
Show heap information.
jcmd <PID> GC.class_histogram
Show a class histogram.
jcmd <PID> Thread.print
Print thread information.
And:
jcmd <PID> JFR.start
Start a Flight Recording.
This makes jcmd an extremely useful tool for diagnosing a running JVM.
jstat
Another useful command is:
jstat
For example:
jstat -gc <PID> 1000
This periodically displays GC-related statistics.
You can use it when you want a quick view of changing JVM behavior.
For example:
time
↓
heap usage
↗
↗
↘ GC
↗
↗
↘ GC
It can help you see whether the heap is repeatedly filling and being reclaimed.
Profiling I/O
Sometimes CPU and memory look perfectly healthy.
But the application is still slow.
The problem may be I/O.
For example:
Application
↓
HTTP client
↓
Remote service
↓
500 ms
Or:
Application
↓
Database
↓
Slow query
↓
800 ms
Or:
Application
↓
Disk
↓
Slow filesystem operation
Profiling should therefore consider the complete path:
CPU
Memory
GC
Threads
Locks
Network
Disk
Database
A Java profiler isn't a replacement for database monitoring or operating-system monitoring.
Performance is a system property.
Profiling a Spring Boot Application
Imagine a Spring Boot API:
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable Long id) {
return orderService.getOrder(id);
}
The service:
public Order getOrder(Long id) {
Order order = repository.findById(id)
.orElseThrow();
enrich(order);
return order;
}
Suppose the endpoint takes:
700 ms
We profile it.
The result:
HTTP handling 5 ms
Controller 2 ms
Service 15 ms
Database 80 ms
enrich() 10 ms
JSON serialization 25 ms
Waiting 563 ms
Now the most interesting number is:
Waiting = 563 ms
We need to determine what that waiting represents.
Perhaps:
Connection pool
or:
Lock contention
or:
Remote HTTP service
or:
Database connection acquisition
This is why simply looking at method execution time isn't enough.
Profiling Latency
Average latency can be misleading.
Suppose 1,000 requests produce:
999 requests → 50 ms
1 request → 5 seconds
The average might look acceptable.
But users may experience occasional terrible requests.
This is why we often look at:
p50
p90
p95
p99
p99.9
For example:
p50 = 40 ms
p95 = 80 ms
p99 = 120 ms
p99.9 = 2,000 ms
The application is usually fast.
But something causes occasional severe latency spikes.
Potential causes include:
GC pause
Lock contention
Database latency
Network latency
Thread pool exhaustion
Connection pool exhaustion
Safepoint delays
External services
Profiling helps investigate these cases.
Safepoints
The JVM occasionally needs to bring threads to a state where certain JVM operations can safely proceed.
These are called:
Safepoints
Conceptually:
Application threads
↓
Reach safepoint
↓
JVM operation
↓
Threads continue
Modern JVMs perform many operations concurrently, and the exact behavior depends on the JVM and operation.
But safepoint behavior can still matter when investigating unusual latency or JVM pauses.
This is another reason JVM profiling is more complicated than simply measuring Java methods.
JIT Compilation and Profiling
Remember what happens when Java runs:
Java code
↓
Bytecode
↓
Interpreter / compiled code
↓
JIT optimization
↓
Native machine code
The JIT observes runtime behavior.
Therefore, performance can change over time.
For example:
Application starts
↓
Methods interpreted
↓
Hot methods identified
↓
JIT compilation
↓
Optimized machine code
↓
Performance changes
This is why a Java application can behave differently during:
startup
and:
steady state
Profiling should therefore be performed under representative workload.
Why Benchmarks Can Be Misleading
Consider this:
long start = System.nanoTime();
calculate();
long end = System.nanoTime();
System.out.println(end - start);
It looks like a benchmark.
But JVM optimization makes benchmarking more complicated.
The JIT can perform:
Inlining
Dead-code elimination
Constant folding
Escape analysis
Loop optimizations
Devirtualization
The JVM may also need time to warm up.
Therefore:
run once
is not a reliable way to measure JVM code.
JMH
For microbenchmarks, use:
JMH
JMH stands for:
Java Microbenchmark Harness
It is designed specifically for benchmarking JVM code.
A simple benchmark looks like:
@Benchmark
public int calculate() {
return 10 * 20;
}
JMH handles important benchmarking concerns such as:
Warmup
Iterations
Forks
Measurement
JIT effects
Result reporting
So the distinction is:
Profiler
"What is slow in my application?"
while:
JMH
"How fast is this particular piece of code?"
These tools solve different problems.
Profiling vs Monitoring vs Benchmarking
These concepts are related but different.
Monitoring
Answers:
"What is happening right now?"
Examples:
CPU
Memory
Requests/sec
Latency
Errors
GC
Profiling
Answers:
"Where is the application spending its resources?"
Examples:
Hot methods
Allocations
Locks
Thread states
GC behavior
Benchmarking
Answers:
"How fast is this operation under controlled conditions?"
Example:
ArrayList vs LinkedList
or:
JSON serializer A vs B
A production investigation might use all three.
A Real Performance Investigation
Suppose users report:
"The API became slow."
Don't immediately change code.
Start by defining the problem.
For example:
Before:
p99 latency = 200 ms
After:
p99 latency = 1,200 ms
Now investigate.
API latency
│
▼
JFR recording
│
+-----------+-----------+
│ │ │
CPU GC Threads
│ │ │
▼ ▼ ▼
Normal Normal BLOCKED
│
▼
Database lock
Now we have a hypothesis:
Database lock contention
We investigate the database.
We fix the locking problem.
Then measure again:
p99 = 1,200 ms
↓
p99 = 180 ms
That is performance engineering.
A Practical Profiling Workflow
A good workflow is:
1. Define the problem
↓
2. Establish baseline
↓
3. Reproduce or observe
↓
4. Capture profiling data
↓
5. Identify bottleneck
↓
6. Form hypothesis
↓
7. Make one meaningful change
↓
8. Measure again
↓
9. Compare with baseline
↓
10. Keep or revert the change
Let's look at each step.
Step 1: Define the Problem
Don't say:
"The application is slow."
Say:
"p99 latency increased from 200 ms to 900 ms."
Or:
"CPU increased from 40% to 90%."
Or:
"Heap usage grows continuously."
A measurable problem gives you something to investigate.
Step 2: Establish a Baseline
Before changing anything, record:
CPU
Memory
GC
Throughput
Latency
Error rate
Thread count
Database latency
For example:
Throughput 1,000 req/s
p50 40 ms
p95 80 ms
p99 150 ms
CPU 55%
Heap 4 GB
GC 5%
Now you have a baseline.
Step 3: Capture the Application Under Realistic Load
Profiling an idle application won't tell you much.
If the problem occurs when:
1,000 requests/sec
then profile under approximately that workload.
Otherwise you might investigate behavior that isn't relevant to the actual problem.
Step 4: Capture JFR
A JFR recording gives you a broad view.
For example:
jcmd <PID> JFR.start
Let the application run under the relevant workload.
Then:
jcmd <PID> JFR.stop filename=performance.jfr
Open the recording in JDK Mission Control.
Start broad.
Don't immediately inspect individual methods.
First ask:
CPU?
GC?
Memory?
Threads?
Locks?
I/O?
Latency?
Step 5: Narrow the Investigation
Suppose JFR shows:
CPU → normal
GC → normal
Memory → normal
Threads → high contention
Now focus on threads and locks.
Or:
CPU → 95%
GC → normal
Focus on CPU hotspots.
Or:
CPU → normal
GC → high
Focus on allocation and garbage collection.
The profiler helps you narrow the search space.
Step 6: Form a Hypothesis
Don't stop at:
"Method X is hot."
Form a hypothesis:
"Method X is hot because it is called once for every item
and performs an expensive calculation."
Then test the hypothesis.
This is the difference between profiling and simply looking at profiler graphs.
Step 7: Make One Meaningful Change
Suppose:
calculatePrice()
is called repeatedly.
You might introduce caching.
Or reduce duplicate computation.
Or improve an algorithm.
Or change a database query.
Make a meaningful change.
Don't simultaneously change:
GC settings
Thread pool
Database
Caching
Algorithm
because then you won't know what actually helped.
Step 8: Measure Again
After the change:
Before
p99 = 900 ms
After:
p99 = 250 ms
Now you have evidence.
If nothing changed:
p99 = 890 ms
your hypothesis was probably wrong or incomplete.
Go back to profiling.
Don't Optimize What Isn't the Bottleneck
Suppose profiling shows:
Database 700 ms
Java code 20 ms
Serialization 10 ms
Don't spend two days optimizing:
for (...)
or:
stream()
or:
HashMap
The application is spending most of its time somewhere else.
This is an extremely common performance mistake.
The 80/20 Principle in Profiling
Often, a small amount of code accounts for a large portion of resource consumption.
You might find:
Method A 55%
Method B 20%
Method C 8%
Everything else 17%
You don't need to optimize everything.
Start with:
Method A
If you reduce its cost significantly, overall performance can improve dramatically.
This is why profilers are so useful.
They tell you where to concentrate your effort.
Don't Blame Java Too Quickly
Suppose:
Application latency = 1 second
It is tempting to say:
"Java is slow."
But profiling might show:
Java computation 20 ms
Database 600 ms
Network 300 ms
Serialization 20 ms
Other 60 ms
Java itself isn't the problem.
Similarly:
CPU = 100%
doesn't automatically mean:
"Java is inefficient."
It could be:
A valid CPU-intensive workload
or:
An inefficient algorithm
or:
Excessive serialization
or:
Too many allocations
Profiling gives us the evidence.
The Java Performance Toolkit
A practical toolkit looks like this:
Java Performance
│
+----------------+----------------+
│ │ │
JFR async-profiler JMH
│ │ │
Runtime Detailed Benchmarks
behavior profiling
│ │
▼ ▼
JMC Flame graphs
│
▼
Analysis
And alongside these:
jcmd
jstat
Thread dumps
Heap dumps
Eclipse MAT
OS monitoring
Database monitoring
Each tool answers a different question.
When Should You Use Which Tool?
A simple decision guide:
CPU is high
↓
JFR / async-profiler
Latency is high but CPU is low
↓
Wall-clock profiling
Threads
Locks
I/O
Database
GC is high
↓
JFR
Allocation profiling
GC logs
Memory continuously grows
↓
Heap dump
MAT
Allocation profiling
Threads are blocked
↓
Thread dump
JFR
Lock profiling
Need to compare two implementations
↓
JMH
Need to understand production behavior
↓
JFR
JMC
Profiling Is About Asking Better Questions
A profiler doesn't automatically fix your application.
It gives you evidence.
The developer still needs to ask:
Why is this method hot?
Why are so many objects allocated?
Why are these objects retained?
Why are threads blocked?
Why is this request waiting?
Why is GC consuming CPU?
Why is latency high only at p99?
Why does performance change after startup?
This is where understanding JVM internals becomes extremely valuable.
If you understand:
JIT
GC
Heap
Threads
Stacks
Locks
Safepoints
Class loading
then profiler output becomes much easier to understand.
Without that knowledge, a profiler can look like a collection of confusing graphs.
The Most Important Rule
There is one principle that matters more than any particular profiling tool:
Measure
before
optimizing.
Don't start with:
"I think this is slow."
Start with:
"Let's find out."
Don't say:
"GC must be the problem."
Say:
"Let's examine GC behavior."
Don't say:
"This method looks expensive."
Say:
"The profiler shows this method consumes 40%
of CPU samples."
Then investigate why.
Putting Everything Together
A Java application is a runtime system.
When it runs, many things happen simultaneously:
Java Application
│
+------------------+------------------+
│ │ │
CPU Memory Threads
│ │ │
JIT GC Locks
│ │ │
+------------------+------------------+
│
I/O
│
+------------+------------+
│ │
Database Network
Performance problems can originate anywhere in this system.
Profiling gives us a way to observe that system.
The overall process becomes:
Performance Problem
│
▼
Measure
│
▼
Profile
│
+--------------+--------------+
│ │ │
CPU Memory Threads
│ │ │
JIT GC Locks
│ │ │
+--------------+--------------+
│
▼
Find Bottleneck
│
▼
Form Hypothesis
│
▼
Optimize
│
▼
Measure
│
▼
Verify Improvement
The goal of performance profiling isn't to make every method faster.
It is to find the small number of things that actually matter.
Once you start working this way, performance optimization becomes much less about guessing and much more about engineering.
You don't optimize because something looks inefficient.
You optimize because you have evidence that it matters.
And after the optimization, you measure again to prove that it actually helped.
That is the real purpose of Java performance profiling.