August 30, 2026 54 minutes minutes read Admin

Java Performance Profiling - Finding Out Why Your Java Application Is Slow

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.