JFR Event Streaming: Continuous Production Profiling Without a Vendor Agent
Your JVM already ships with a profiler that is safe for production. Since JDK 14, it can also talk to you in real time.
Why You May Not Need an Agent
Many teams reach for a vendor agent the moment production slows down. However, the JDK has carried its own recorder for years. JEP 328 brought Flight Recorder into OpenJDK 11, according to Oracle’s Java Magazine. Until Java 14, though, it only produced files that you had to dump and open later.
That changed with JEP 349. It exposes JFR data for continuous monitoring, and the JEP notes that HotSpot emits more than 500 data points through JFR. As a result, you can react to events while the application is still running.
How Event Streaming Works
Inside the Same JVM
The entry point is RecordingStream in the jdk.jfr.consumer package. You enable the events you care about, register a handler, and start the stream. Each handler is simply a lambda that runs when an event arrives.
From Another Process
Streaming also works from outside. JEP 349 explains that the disk repository location is stored in the jdk.jfr.repository system property, which you can read through the attach API. In addition, RemoteRecordingStream in the jdk.management.jfr module lets a separate process consume events, as the Inside Java overview shows.
A Small Example You Can Run
The program below needs JDK 14 or newer (I ran it on JDK 21). Save it as Stream.java, then start it with the single command underneath. It prints CPU load every second and reports any lock wait longer than 10 ms.
import java.time.Duration;
import jdk.jfr.consumer.RecordingStream;
public class Stream {
public static void main(String[] args) throws Exception {
try (var rs = new RecordingStream()) {
rs.enable("jdk.CPULoad").withPeriod(Duration.ofSeconds(1));
rs.enable("jdk.JavaMonitorEnter")
.withThreshold(Duration.ofMillis(10));
rs.onEvent("jdk.CPULoad", e -> System.out.printf(
"jvm user cpu: %.1f%%%n", e.getFloat("jvmUser") * 100));
rs.onEvent("jdk.JavaMonitorEnter", e -> System.out.println(
"lock wait: " + e.getDuration().toMillis() + " ms"));
rs.startAsync();
Object lock = new Object();
Thread t = new Thread(() -> {
synchronized (lock) {
try { Thread.sleep(300); } catch (Exception x) { }
}
});
t.start();
Thread.sleep(50);
synchronized (lock) { }
Thread.sleep(3000);
}
}
}
java Stream.java
On my machine it printed a lock wait of roughly 250 ms, followed by one CPU line per second. Your exact numbers will differ, naturally.
What Does It Cost?
Overhead is the question every operator asks first. The JDK answers it inside its own configuration files. The default setting is described as safe for continuous production use, typically under 1% overhead, while profile is described as typically around 2%. Notice that these are documented targets, not measurements from my workloads, so test your own service before trusting them.
| Setting | Documented overhead | Intended use |
|---|---|---|
| default | Typically under 1% | Always-on production recording |
| profile | Typically around 2% | Short, focused investigations |
The difference comes from how aggressively each setting collects data. I read both files from the JDK 21 installation on my machine. The profile setting simply halves every threshold, so it catches shorter waits and slower calls.
Similarly, profile samples twice as often. Converting the configured values into samples per second makes this easy to see.
One more caution applies here. According to the Inside Java overview, how you implement a stream can itself affect performance. Therefore, keep handlers short, and push heavy work onto another thread.
A Practical Production Setup
First, start the JVM with a rolling on-disk recording. Replace app.jar with your own application.
java -XX:StartFlightRecording:name=prod,settings=default,maxage=6h,disk=true -jar app.jar
Later, if something looks wrong, copy the recent data to a file with jcmd. The JVM prints this hint itself at startup. Replace <pid> with your process id.
jcmd <pid> JFR.dump name=prod filename=/tmp/prod.jfr
Quick checklist
- Start with the
defaultsetting, neverprofile, for always-on use. - Enable only the events you actually read.
- Keep stream handlers fast and non-blocking.
- Set
maxageso the disk repository stays bounded. - Measure overhead on your own workload first.
Streaming Versus Dump Files
| Question | Event streaming | Dump file |
|---|---|---|
| When do you see data? | As events arrive | After you dump |
| Best for | Alerts and live metrics | Deep post-incident analysis |
| Extra tooling | A small consumer program | A viewer such as JDK Mission Control |
To be fair, a vendor product still adds dashboards, long-term storage, and cross-service views that JFR does not provide on its own. Nevertheless, for many teams the built-in route covers the first and most urgent questions at no licence cost.
What We Have Learned
JFR has been part of OpenJDK since version 11, and JEP 349 added live event streaming in Java 14. A RecordingStream lets one JVM watch itself, while a remote stream and the disk repository allow outside consumers. The documented overhead is typically under 1% for the default setting and around 2% for profile, and profile simply samples twice as aggressively. Consequently, a rolling default recording plus a small stream consumer gives you continuous production insight without installing a vendor agent. Just measure on your own system, and keep your handlers light.



