The most surprising thing about profiling Java applications is that you’re usually looking at the wrong thing.

Let’s see what’s actually happening. Imagine you have a Spring Boot app that’s feeling sluggish. You want to dig in.

First, JFR (Java Flight Recorder). It’s built into the JVM, no external agents needed. It’s low-overhead, designed for production.

Here’s how you start it:

java -XX:StartFlightRecording=duration=60s,filename=myrecording.jfr -jar myapp.jar

This spins up myapp.jar and records JFR events for 60 seconds, saving to myrecording.jfr.

Now, to analyze myrecording.jfr, you’d typically use JDK Mission Control (JMC). Load the file, and you’ll see various views:

  • Thread Dump Analysis: Shows what threads were doing, blocked, sleeping, etc.
  • Method Profiling: This is key. It shows CPU time spent in different methods.
  • Event Statistics: JFR records tons of events – GC, exceptions, locks, I/O. You can see which are frequent or costly.

If you see a hot method in JMC, say com.example.MyService.processData(), and it’s taking up 30% of CPU time, that’s your starting point. JMC might show you the call stack leading to it.

But JFR is more about what happened. For how it happened, especially CPU-bound issues, Async-Profiler is often better. It’s a sampling profiler, meaning it periodically interrupts threads to see what they’re doing. It has less overhead than traditional instrumentation profilers and can run on live systems.

To use it, you download the async-profiler.jar and attach it to your running JVM.

# Find your Java process ID (PID)
jps -l | grep myapp

# Attach async-profiler
java -jar async-profiler.jar -d 60000 -f profile.html <pid>

This runs for 60 seconds (-d 60000 is milliseconds) and outputs an HTML report (-f profile.html). This report is usually an interactive flame graph.

A flame graph is brilliant. The wider a bar, the more time is spent in that function (or its children). You read it from bottom to top. The bottom is the kernel, then the JVM, then your application code.

If you see a wide bar at the top, say for com.example.MyService.processData(), that’s a strong indicator of a CPU bottleneck. You can click on it to zoom in and see its callers and callees.

What problem does this solve? Spring Boot apps, especially complex ones with many dependencies and heavy business logic, can become performance black boxes. You deploy, and it’s slow, but you don’t know why. Profilers give you concrete data to pinpoint the bottlenecks, whether it’s inefficient algorithms, excessive object creation, lock contention, or slow I/O.

Internally, JFR works by having the JVM emit events for specific points in its execution. The JVM’s JIT compiler, garbage collector, thread scheduler, and even user code (if instrumented) can trigger these events. JFR collects these events in a buffer and writes them to disk. Async-Profiler, on the other hand, uses libunwind (on Linux) or mach_thread_self (on macOS) to walk the call stack of running threads at regular intervals without modifying the JVM’s code.

The exact levers you control are usually the sampling interval (for async-profiler) or the JFR event settings (which specific events to record). For JFR, you can also configure buffer sizes and thresholds. You can enable specific JFR event categories like jdk.GarbageCollection, jdk.ObjectAllocation, jdk.ThreadPark, jdk.JavaMonitor (for locks).

A common misconception is that a hot method shown in a profiler is always the root cause of slowness. Often, the time spent in that method is a symptom of something else. For example, a method might appear hot because it’s repeatedly being called due to excessive garbage collection. In such cases, optimizing the hot method directly might yield little improvement; you’d need to address the GC pressure instead, perhaps by reducing object allocations elsewhere. JFR’s allocation profiling and GC event data are crucial here.

The next performance challenge you’ll likely face is understanding and optimizing garbage collection behavior.

Want structured learning?

Take the full Spring-boot course →