Java Flight Recorder Tutorial: How to Profile Java Applications
Transcript:
Java Flight Recorder is a profiling and diagnostics tool built into the OpenJDK. Let’s look at how we can use it with Java applications, both locally and in containers, and how to analyze the recordings in Java Mission Control. We will go through both theory and practice, so if you are already familiar with JFR, you can jump directly to the practical section.
JFR, or Java Flight Recorder, is a powerful tool for troubleshooting Java application performance at runtime. Think of it as a flight recorder for your JVM. It collects runtime events with timestamps and stores them in binary log files. These recordings can be analyzed in Java Mission Control, an open-source graphical tool for diagnostics.
JFR can record CPU usage, garbage collection activity, thread behavior, memory allocation, I/O, and many other runtime aspects. It supports hundreds of event types out of the box, and you can also define custom events.
In simple terms, JFR helps answer questions like what the JVM is doing, where time is spent, whether threads are blocked or overloaded with allocations, and whether garbage collection or CPU usage shows abnormal behavior.
If you are just starting with JFR, here are a few key points to understand. It is built into OpenJDK and does not require separate installation. It has very low overhead, making it suitable for production troubleshooting. You can start recordings at JVM startup or attach later using the jcmd tool. JFR also supports continuous recordings and includes command-line tools such as jfr summary and jfr view.
However, JFR is not a universal profiling tool. There are cases where it is not the best fit.
For deep native profiling, JFR is limited because it focuses on JVM events rather than kernel-level or native library behavior. In such cases, tools like perf or native profilers are more appropriate.
For distributed systems, JFR is not ideal because it captures only a single JVM’s event stream and cannot reconstruct full request flows across services, queues, and databases. Distributed tracing tools are better suited for that.
For database profiling, JFR can show that threads are waiting on I/O, but it cannot precisely explain why a query is slow. For that, you need database-level tools or application-level instrumentation.
Now let’s look at how to use JFR.
You can start a recording at application startup using JVM flags, including options for duration and output file name. Additional flags such as unlock diagnostic VM options and debug non-safe points can improve profiling accuracy by enabling more detailed event capture.
Alternatively, you can use jcmd, a built-in JDK tool, to start or stop recordings at runtime. This allows you to profile a running application at any point.
JFR also supports continuous recording mode. In this mode, you can limit event types to reduce overhead and configure file rotation. In JDK 25, the default maximum size for continuous recordings is 250 MB, but this can be adjusted. You can also combine maximum size with maximum age to control retention. For example, setting a maximum age of one hour keeps only the last hour of data. Additionally, setting dump on exit to true ensures the recording is saved when the JVM shuts down.
JFR can also be used inside containers.
With Dockerfiles, you can start a JFR recording at container startup. In a typical setup, you build the application in one stage using a hardened Liberica JDK image based on Alpine Linux, then run it in a minimal JRE-based runtime image. A hardened image reduces the attack surface and improves security.
In the entry point, you pass JFR options, and the recording starts automatically when the container launches.
If you use Buildpacks, enabling JFR is even simpler. Setting BP_JFR_ENABLED to true automatically starts recording. By default, the recording is written to recording.jfr in the /tmp directory when the JVM exits. You can customize this using BP_APPEND_JAVA_TOOL_OPTIONS to control duration and file name. The file can later be copied from the container using docker cp.
To profile a running container, you can use jcmd. However, since jcmd is part of the JDK and not the JRE, it is not typically included in production images. Production containers are often minimal or distroless and may not even include a shell.
To solve this, you can use ephemeral containers. These are temporary containers started for debugging or profiling purposes. They run alongside the production container for a short time.
In this approach, you deploy the application without JFR enabled, then start an ephemeral container with the JDK. This container shares the same process namespace as the application. You use jcmd to find the JVM process ID and then start a JFR recording. After profiling is complete, you extract the recording using docker cp.
Now let’s move to JFR analysis.
When you open a JFR file in Java Mission Control, it first shows automated analysis results with rule-based insights. These highlight potential issues such as memory pressure, GC activity, thread contention, or excessive exceptions.
This view is useful as a starting point, but it should not be treated as a final diagnosis. It simply guides you toward areas that deserve deeper inspection.
In our example, the recording shows heavy activity during Spring Boot startup, including memory usage, socket reads, and many exceptions. At first glance, this might look alarming, but context is important.
The garbage collection analysis shows frequent but very short pauses, typically just a few milliseconds. This indicates that GC is active but not a major bottleneck.
Memory analysis shows that most allocations are in byte arrays, followed by strings, integers, and class loading-related objects. This is typical during application startup due to framework initialization, class loading, and buffering.
Thread analysis helps determine whether the JVM is actively processing or mostly waiting. Zooming into thread timelines provides deeper insight into execution states.
Method profiling shows where execution time is spent at the method level. In this case, bcrypt encipher appears near the top, indicating password hashing activity. This is expected in authentication or security initialization scenarios.
The exceptions view shows a high number of exceptions. However, many of them are related to class loading, reflection, and framework internals rather than actual business logic failures. This is common during startup phases.
JFR also captures JVM and hardware configuration details, which provide important context for analyzing recordings or sharing them with performance engineers.
Overall, this example shows that JFR is highly context dependent. What initially looks like a problem may simply be normal startup behavior in a Spring Boot application, including class loading, dependency initialization, memory allocation, and I/O activity.
JFR provides the data needed to move from assumptions to evidence and understand what the JVM is truly doing at runtime.





