JVM Flight Recorder (JFR) is a tool that lets you profile your Java application with virtually no performance impact, even in production.

Let’s see JFR in action. Imagine we have a simple Java application that simulates some work, maybe a web server handling requests.

import java.util.concurrent.TimeUnit;

public class WorkSimulator {

    public static void main(String[] args) throws InterruptedException {
        System.out.println("Starting work simulator...");
        while (true) {
            doSomeWork();
            TimeUnit.MILLISECONDS.sleep(100); // Simulate request processing time
        }
    }

    private static void doSomeWork() throws InterruptedException {
        // Simulate CPU-bound work
        long startTime = System.nanoTime();
        while (System.nanoTime() - startTime < 5000000) { // 5ms of busy work
            // No-op
        }
        // Simulate I/O or blocking operation
        TimeUnit.MILLISECONDS.sleep(2);
    }
}

To profile this with JFR, we start the JVM with a specific flag. We’ll use jcmd to attach to a running JVM, but you could also specify this when starting the application.

First, find the process ID (PID) of your Java application.

jps -l

Let’s say our WorkSimulator has PID 12345. Now, we can start recording:

jcmd 12345 JFR.start name=work-profile settings=profile duration=60s filename=work.jfr

This command tells the JVM with PID 12345 to start a JFR recording named work-profile. It will use the profile settings (a built-in configuration for general profiling) and run for 60s (60 seconds), saving the data to work.jfr.

After 60 seconds, the recording will stop automatically. You can then analyze work.jfr using tools like JDK Mission Control (JMC).

JFR works by instrumenting the JVM itself, rather than your application code. When you start JFR, it begins collecting events that the JVM is already generating internally for diagnostic purposes. These events are stored in a circular buffer in memory. When the buffer is full, older events are discarded, minimizing memory overhead. The data is then written to a file, typically in a compact binary format.

The key is that JFR leverages events that are already occurring within the JVM. Things like garbage collection cycles, thread state changes (running, sleeping, blocked), method calls (if configured), lock contention, and I/O operations are all emitted as events. JFR collects a subset of these events based on the selected recording template. Because it’s part of the JVM, it has direct access to this low-level information without needing to inject bytecode or significantly alter the application’s execution flow. The "zero-overhead" claim is a bit of a misnomer; there’s always some overhead, but it’s typically negligible (often less than 1-3%) in production environments, especially compared to traditional profilers that might pause the application or add significant instrumentation.

The real power comes from the granularity. You can configure JFR to record very specific types of events, or use pre-defined templates like profile (for general performance), default (for basic diagnostics), or low (for minimal impact). For example, to see detailed information about garbage collection, you’d use a template that includes GC events.

The settings parameter in JFR.start is crucial. It points to a JFR recording template. These templates define which events are enabled and their granularity.

  • default: A good starting point for general diagnostics.
  • profile: Enables more detailed event collection for performance analysis, including method profiling. This is what we used.
  • low: Minimal event collection, for situations where even the default template might introduce too much overhead.
  • Custom templates: You can create your own .jfc files to precisely define the events and thresholds you want to monitor.

Here’s how you might start a recording with a custom template named my-custom.jfc to focus on lock contention and GC:

jcmd 12345 JFR.start name=custom-analysis settings=my-custom.jfc duration=300s filename=custom.jfr

The most surprising thing is how much detail you can get about thread activity. When you look at a JFR recording in JMC, you’ll see timelines showing threads entering and exiting various states: Running, Runnable, Sleeping, Waiting, Blocked, Parking. You can zoom into specific periods and see exactly which thread was doing what, which locks it was waiting for, and the call stack at that moment. This level of insight into thread synchronization and execution flow is invaluable for diagnosing deadlocks, performance bottlenecks related to contention, and understanding complex concurrent behavior without significantly impacting the application’s performance.

Once you have the work.jfr file, you can open it in JDK Mission Control. This graphical tool allows you to visualize the collected data. You’ll see charts for CPU usage, thread activity, garbage collection, I/O, and more. You can drill down into specific events, see call trees, and analyze the recorded stack traces to pinpoint performance issues.

The next step after analyzing JFR data is often to modify your application’s configuration or code based on the insights gained, and then re-profile to confirm the fix.

Want structured learning?

Take the full Jvm course →