Why Java Flight Recorder Profiling Belongs in Production
Most teams profile the wrong JVM. They reproduce a problem on a laptop, attach a sampling profiler, and study a workload that shares almost nothing with production traffic. Java Flight Recorder profiling solves that by being cheap enough to leave running on the real thing — the JVM serving real requests, with real heap pressure and real GC behaviour.
JFR is not a bolt-on agent. It is built into the JVM itself, and since JDK 11 it ships with every OpenJDK build under the GPL, so there is no licence to buy and no third-party library to vendor. The recorder taps instrumentation the JVM already maintains internally, which is why its default profile costs around 1% throughput instead of the 10-30% a naive instrumenting profiler will happily charge you.
What JFR Records That Metrics Cannot Tell You
A metrics dashboard tells you that p99 latency doubled at 14:20. It cannot tell you why. JFR records discrete, timestamped events with stack traces attached — every long GC pause, every thread that parked on a monitor, every socket read that blocked for more than a threshold, every object allocation sampled from the TLAB path.
That difference matters when you are chasing something rare. A metric averages your outlier into invisibility, but an event survives with the exact stack that caused it. So when you already export request rates and timers through Micrometer observability in Spring Boot, treat JFR as the layer underneath: metrics tell you when to look, JFR tells you where.
Starting a Recording Without Restarting the JVM
You can arm JFR at launch, but the more useful skill is attaching to a JVM that is already misbehaving. The jcmd tool does this against a running process, and nothing about the application needs to change first.
# See what is running
jcmd -l
# Start a 5-minute recording on PID 4821, dump to disk when it ends
jcmd 4821 JFR.start name=triage settings=profile duration=5m \
filename=/tmp/triage.jfr
# Or arm at startup with a continuous ring buffer that never fills the disk
java -XX:StartFlightRecording=name=always-on,settings=default,\
maxsize=256m,maxage=6h,disk=true \
-XX:FlightRecorderOptions=repository=/var/log/jfr \
-jar app.jar
# Dump whatever is in the buffer right now, without stopping the recording
jcmd 4821 JFR.dump name=always-on filename=/tmp/snapshot.jfr
That last pattern is the one worth adopting as a default. A continuous recording with maxage=6h keeps a rolling six-hour window on disk and discards the rest, so when someone reports “it was slow around lunchtime” you dump the buffer and you already have the evidence. Without it, you are asking an intermittent bug to happen again while you watch.
default Versus profile, and Writing Your Own Settings
JFR ships two built-in settings profiles, and the gap between them is where the overhead conversation actually lives. The default profile targets under 1% and is safe to leave on permanently. The profile profile samples far more aggressively — roughly 2% or a little more — and is what you switch to for a bounded investigation.
Neither is sacred. Settings are just XML, and you can copy $JAVA_HOME/lib/jfr/default.jfc and tune individual events. The usual reason to do this is that you want one expensive event without paying for all of them.
<!-- custom.jfc: default profile, but with allocation sampling turned up -->
<event name="jdk.ObjectAllocationSample">
<setting name="enabled">true</setting>
<setting name="throttle">150/s</setting>
</event>
<!-- Only care about socket reads that actually hurt -->
<event name="jdk.SocketRead">
<setting name="enabled">true</setting>
<setting name="threshold">20 ms</setting>
<setting name="stackTrace">true</setting>
</event>
<!-- Expensive. Leave off unless you are hunting a specific leak -->
<event name="jdk.OldObjectSample">
<setting name="enabled">false</setting>
</event>
Note the threshold setting on jdk.SocketRead. Thresholds are JFR’s real overhead lever: the event only materialises when the operation exceeds the duration you set, so a 20ms floor means healthy reads cost nothing and only the pathological ones get recorded with a stack trace. Raise thresholds before you start disabling events wholesale.
Reading a Recording: jfr CLI First, JMC Second
JDK Mission Control gets the attention, but the jfr command-line tool is already in your JDK and answers most questions faster. It is the right first move on a server where you would rather not copy a 200MB file to your laptop.
# What is even in this file?
jfr summary /tmp/triage.jfr
# Every GC pause over 100ms, as readable text
jfr print --events jdk.GCPhasePause \
--filter "duration > 100ms" /tmp/triage.jfr
# Where are allocations coming from?
jfr print --events jdk.ObjectAllocationSample \
--stack-depth 20 /tmp/triage.jfr | head -100
# Machine-readable, for scripting a regression check
jfr print --json --events jdk.ExecutionSample /tmp/triage.jfr
Once you know roughly what you are looking at, open the file in JDK Mission Control for the flame graphs and the automated analysis tab, which flags common pathologies without you asking. The JFR API documentation covers the full event catalogue if you need to know exactly what a given event type carries.
Event Streaming: Consuming JFR Without Files
JDK 14 added jdk.jfr.consumer.RecordingStream, and it quietly changed what JFR is for. Instead of dumping a file and analysing it later, you subscribe to events as the JVM emits them and react in-process. This turns JFR from a forensics tool into a monitoring source.
try (var rs = new RecordingStream()) {
rs.enable("jdk.GCPhasePause")
.withThreshold(Duration.ofMillis(200));
rs.enable("jdk.CPULoad")
.withPeriod(Duration.ofSeconds(10));
rs.onEvent("jdk.GCPhasePause", event -> {
// Ship the pause straight to your metrics backend
Metrics.timer("jvm.gc.pause",
"phase", event.getString("name"))
.record(event.getDuration());
});
rs.onEvent("jdk.CPULoad", event ->
log.info("JVM CPU: {}%",
event.getFloat("jvmUser") * 100));
rs.startAsync(); // non-blocking; keeps running with the app
}
A word of caution, because this API is easy to misuse. Your event handler runs on JFR’s streaming thread, so anything slow or blocking in there creates backpressure on the recorder itself. Do the cheap thing — increment a counter, hand off to a queue — and never make a network call inline.
Catching Regressions Before They Ship
JFR earns its keep in CI too. Run your load test with a recording armed, then assert on the numbers rather than eyeballing a graph. Because jfr print --json gives you structured output, the check is a few lines of shell and a JSON query — allocation rate above a ceiling fails the build, total GC pause time above a budget fails the build.
This works best against a realistic workload, so it pairs naturally with the container-backed setup described in our Spring Boot Testcontainers integration testing guide. A regression gate is only as honest as the traffic it replays.
Where JFR Falls Down
It is not a universal answer, and pretending otherwise wastes people’s time. JFR’s execution sampler only walks threads at safepoints, so it inherits safepoint bias — tight loops that never poll a safepoint can be under-represented in your flame graph. If that bias is the thing you are fighting, async-profiler’s AsyncGetCallTrace approach is the honest alternative.
JFR is also JVM-scoped and blind below the JVM. It will not tell you the kernel spent your latency budget in a network stack or a noisy neighbour stole your CPU; for that you want eBPF-level visibility of the kind covered in our eBPF observability with Cilium and Hubble guide. And the jdk.OldObjectSample event, the one everybody wants for leak hunting, is genuinely expensive — enable it for an investigation, then turn it back off.
One more honest caveat: virtual threads changed the arithmetic. A recording that samples millions of short-lived virtual threads produces far more data than the same settings did against a platform-thread pool, so revisit your maxsize before assuming old settings still hold. The trade-offs there overlap with our comparison of virtual threads and reactive Spring Boot.
Java Flight Recorder profiling is the rare performance tool with no adoption cost: it is already in your JDK, it is safe to leave armed at roughly 1% overhead, and a rolling maxage buffer means the next intermittent incident arrives with evidence attached instead of a request to reproduce it. Start with a continuous default recording and the jfr CLI, reach for profile settings and Mission Control when you have a specific question, and add RecordingStream once you want the JVM reporting on itself in real time. Just keep its blind spots in mind — safepoint bias and the kernel below you — and pair it with tools that see what it cannot.