Lesson 53 of 71
JFR, async-profiler, and Flame Graphs
JDK Flight Recorder (JFR) is an event recorder built into HotSpot. It records JVM events (GC, compilation, locks, allocation samples, thread sleeps) and application-defined events into a binary file with low overhead, so it can run in production. async-profiler is a separate open-source sampling profiler for HotSpot that draws flame graphs. This lesson shows how to start JFR, define and read events from code, and how to read profiles from both tools.
| Item | Purpose |
|---|---|
-XX:StartFlightRecording, jcmd JFR.* |
Start and dump recordings without code changes |
jdk.jfr.Recording |
Start, configure, stop, and dump a recording from code |
jdk.jfr.Event |
Base class of custom events |
jdk.jfr.consumer.RecordingFile |
Read events from a .jfr file |
jdk.jfr.consumer.RecordingStream |
Receive events while they are recorded |
async-profiler cpu, alloc, lock, wall |
Sample where time, allocation, and contention come from |
| Flame graph | A picture of many stack samples merged by common prefix |
The examples write recordings to temporary files and print event counts and sorted event names, never times or stack addresses.
Example 1: a custom event, recorded and read back
A custom event is a class that extends jdk.jfr.Event. Calling commit() writes it to every running recording that enables it. RecordingFile.readAllEvents reads a dumped file back as RecordedEvent objects.
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import jdk.jfr.Event;
import jdk.jfr.Name;
import jdk.jfr.Recording;
import jdk.jfr.consumer.RecordedEvent;
import jdk.jfr.consumer.RecordingFile;
public class FirstRecording {
@Name("demo.OrderAccepted")
static class OrderAccepted extends Event {
long orderId;
}
public static void main(String[] args) throws Exception {
Path file = Files.createTempFile("first", ".jfr");
try (Recording recording = new Recording()) {
recording.enable("demo.OrderAccepted");
recording.start();
for (long id = 1; id <= 5; id++) {
OrderAccepted event = new OrderAccepted();
event.orderId = id;
event.commit();
}
recording.stop();
recording.dump(file);
}
List<RecordedEvent> events = RecordingFile.readAllEvents(file);
long count = events.stream()
.filter(e -> e.getEventType().getName().equals("demo.OrderAccepted"))
.count();
long idSum = events.stream()
.filter(e -> e.getEventType().getName().equals("demo.OrderAccepted"))
.mapToLong(e -> e.getLong("orderId")).sum();
System.out.println("OrderAccepted events: " + count);
System.out.println("sum of order ids: " + idSum);
Files.delete(file);
}
}OrderAccepted events: 5
sum of order ids: 15@Name sets the event type name; without it, the name is the fully qualified class name. A new Recording enables nothing until enable is called or a configuration is passed to its constructor. The file also holds metadata events that JFR always writes, which is why the program filters by name.
Example 2: event fields, labels, and duration
An event measures a duration when the code calls begin() before the work and end() (or commit()) after it. @Label, @Category, and @Description are metadata that tools such as JDK Mission Control display. shouldCommit() returns false when the event is disabled or below its threshold, so expensive field values are computed only when needed.
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.TreeMap;
import jdk.jfr.Category;
import jdk.jfr.Event;
import jdk.jfr.EventType;
import jdk.jfr.Label;
import jdk.jfr.Name;
import jdk.jfr.Recording;
import jdk.jfr.consumer.RecordedEvent;
import jdk.jfr.consumer.RecordingFile;
public class TimedEvents {
@Name("demo.Match")
@Label("Order Match")
@Category({"Demo", "Matching"})
static class MatchEvent extends Event {
@Label("Symbol") String symbol;
@Label("Fills") int fills;
}
static int match(String symbol, int qty) {
MatchEvent event = new MatchEvent();
event.begin();
int fills = qty / 100; // the work being measured
event.end();
if (event.shouldCommit()) {
event.symbol = symbol;
event.fills = fills;
event.commit();
}
return fills;
}
public static void main(String[] args) throws Exception {
Path file = Files.createTempFile("timed", ".jfr");
try (Recording r = new Recording()) {
r.enable("demo.Match");
r.start();
match("AAPL", 300);
match("MSFT", 500);
match("AAPL", 200);
r.stop();
r.dump(file);
}
TreeMap<String, Integer> fillsBySymbol = new TreeMap<>();
boolean durationsValid = true;
for (RecordedEvent e : RecordingFile.readAllEvents(file)) {
if (!e.getEventType().getName().equals("demo.Match")) continue;
fillsBySymbol.merge(e.getString("symbol"), e.getInt("fills"), Integer::sum);
durationsValid &= !e.getDuration().isNegative();
}
EventType type = EventType.getEventType(MatchEvent.class);
System.out.println("label: " + type.getLabel());
System.out.println("category: " + type.getCategoryNames());
System.out.println("fills by symbol: " + fillsBySymbol);
System.out.println("durations non-negative: " + durationsValid);
Files.delete(file);
}
}label: Order Match
category: [Demo, Matching]
fills by symbol: {AAPL=5, MSFT=5}
durations non-negative: trueEach event carries a start time, a duration, and the committing thread. By default it also carries a stack trace, which costs time at commit; @StackTrace(false) on the class turns that off for high-rate events.
Example 3: built-in JVM events and thresholds
Built-in events are named jdk.*. A threshold drops events shorter than a given duration, which is how JFR keeps the cost of lock and I/O events low. Here jdk.ThreadSleep records with threshold 0, and the custom event is given a one-hour threshold, so no instance of it is kept.
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Map;
import java.util.TreeMap;
import jdk.jfr.Event;
import jdk.jfr.Name;
import jdk.jfr.Recording;
import jdk.jfr.consumer.RecordedEvent;
import jdk.jfr.consumer.RecordingFile;
public class BuiltInEvents {
@Name("demo.Quick")
static class Quick extends Event {}
public static void main(String[] args) throws Exception {
Path file = Files.createTempFile("builtin", ".jfr");
try (Recording r = new Recording()) {
r.enable("jdk.ThreadSleep").withThreshold(Duration.ZERO);
r.enable("jdk.GarbageCollection");
r.enable("demo.Quick").withThreshold(Duration.ofHours(1));
r.start();
for (int i = 0; i < 3; i++) Thread.sleep(5);
System.gc();
for (int i = 0; i < 10; i++) {
Quick q = new Quick();
q.begin();
q.commit();
}
r.stop();
r.dump(file);
}
Map<String, Integer> counts = new TreeMap<>();
for (RecordedEvent e : RecordingFile.readAllEvents(file)) {
String name = e.getEventType().getName();
if (name.equals("jdk.ThreadSleep") && !e.getThread().getJavaName().equals("main")) continue;
if (name.equals("jdk.ThreadSleep") || name.equals("demo.Quick")) counts.merge(name, 1, Integer::sum);
if (name.equals("jdk.GarbageCollection")) counts.put(name + " seen", 1);
}
System.out.println(counts);
Files.delete(file);
}
}{jdk.GarbageCollection seen=1, jdk.ThreadSleep=3}demo.Quick is missing from the map because all ten events lasted less than an hour. The sleep events come from the main thread; the filter excludes JVM threads that may also sleep. The GC count is printed as "seen" because System.gc() can produce more than one jdk.GarbageCollection event depending on the collector.
Example 4: streaming events with RecordingStream
RecordingStream (Java 14) delivers events to callbacks while the recording runs, so a service can react to its own events, for example export them as metrics. Events arrive in batches about once per second, so the program waits on a latch instead of sleeping.
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import jdk.jfr.Event;
import jdk.jfr.Name;
import jdk.jfr.consumer.RecordingStream;
public class StreamingEvents {
@Name("demo.Reject")
static class Reject extends Event {
String reason;
}
public static void main(String[] args) throws Exception {
CountDownLatch received = new CountDownLatch(4);
AtomicLong limitRejects = new AtomicLong();
try (RecordingStream stream = new RecordingStream()) {
stream.enable("demo.Reject");
stream.onEvent("demo.Reject", e -> {
if (e.getString("reason").equals("LIMIT")) limitRejects.incrementAndGet();
received.countDown();
});
stream.startAsync();
for (String reason : new String[] {"LIMIT", "PRICE", "LIMIT", "HALTED"}) {
Reject r = new Reject();
r.reason = reason;
r.commit();
}
boolean all = received.await(30, TimeUnit.SECONDS);
System.out.println("all events received: " + all);
}
System.out.println("LIMIT rejects: " + limitRejects.get());
}
}all events received: true
LIMIT rejects: 2The callbacks run on a JFR thread, so shared counters use atomics (atomics and CAS). RecordingStream keeps the stream data in its own repository, and closing the stream stops it. EventStream.openRepository() reads the live stream of a recording that another component started.
Example 5: summarizing a recording by event type
Tools such as jfr summary count events per type. The same report in code reads the file once and groups by name, which is the basis of automated checks in a performance test (for example, "no jdk.JavaMonitorEnter events over 10 ms").
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.TreeMap;
import jdk.jfr.Event;
import jdk.jfr.Name;
import jdk.jfr.Recording;
import jdk.jfr.consumer.RecordedEvent;
import jdk.jfr.consumer.RecordingFile;
public class Summary {
@Name("demo.Quote") static class Quote extends Event { double price; }
@Name("demo.Trade") static class Trade extends Event { long qty; }
public static void main(String[] args) throws Exception {
Path file = Files.createTempFile("summary", ".jfr");
try (Recording r = new Recording()) {
r.enable("demo.Quote");
r.enable("demo.Trade");
r.start();
for (int i = 0; i < 7; i++) { Quote q = new Quote(); q.price = 100 + i; q.commit(); }
for (int i = 0; i < 2; i++) { Trade t = new Trade(); t.qty = 50; t.commit(); }
r.stop();
r.dump(file);
}
Map<String, Integer> counts = new TreeMap<>();
try (RecordingFile in = new RecordingFile(file)) {
while (in.hasMoreEvents()) {
RecordedEvent e = in.readEvent();
String name = e.getEventType().getName();
if (name.startsWith("demo.")) counts.merge(name, 1, Integer::sum);
}
}
counts.forEach((name, n) -> System.out.println(name + " " + n));
Files.delete(file);
}
}demo.Quote 7
demo.Trade 2RecordingFile reads one event at a time, so large recordings do not have to fit in memory.
Example 6: JFR from the command line and jcmd
A recording usually starts without code changes. -XX:StartFlightRecording starts one at JVM launch, and jcmd controls recordings in a running JVM by process id. The profile settings sample more often than default, at higher cost. The commands below are representative, not run here.
java -XX:StartFlightRecording=duration=60s,settings=profile,filename=app.jfr -jar app.jar
jcmd <pid> JFR.start name=incident settings=profile maxage=10m
jcmd <pid> JFR.dump name=incident filename=incident.jfr
jcmd <pid> JFR.stop name=incident
jfr summary incident.jfr
jfr print --events jdk.ExecutionSample incident.jfr
jfr view hot-methods incident.jfr| Event | Shows |
|---|---|
jdk.ExecutionSample |
Sampled Java stacks of running threads (CPU profile) |
jdk.ObjectAllocationSample |
Allocation sites, throttled by bytes allocated |
jdk.JavaMonitorEnter |
Contended synchronized entries over the threshold |
jdk.ThreadPark |
Parking in java.util.concurrent locks over the threshold |
jdk.GarbageCollection, jdk.GCPhasePause |
Collections and pauses (GC fundamentals) |
jdk.SafepointBegin |
Safepoint operations (safepoints) |
maxage and maxsize turn a recording into a ring buffer: JFR keeps only the recent data and a dump captures the minutes before an incident.
Example 7: async-profiler and flame graphs
Many sampling profilers ask each thread for its stack only at a safepoint, so samples land on safepoint polls (loop back-edges, method returns) and never inside a counted loop with no poll. This is safepoint bias: the profile blames the wrong lines. async-profiler avoids it by using AsyncGetCallTrace from a signal handler together with Linux perf_events, so it samples a thread wherever it is, including native and kernel frames. JFR's jdk.ExecutionSample also samples outside safepoints.
async-profiler is not installed here; the usage below is representative.
asprof -e cpu -d 30 -f cpu.html <pid> # on-CPU time
asprof -e alloc -d 30 -f alloc.html <pid> # allocation sites, by bytes
asprof -e lock -d 30 -f lock.html <pid> # contended locks, by wait time
asprof -e wall -t -d 30 -f wall.html <pid> # all threads, running or blocked, per thread
java -agentpath:/path/libasyncProfiler.so=start,event=cpu,file=out.html -jar app.jarA flame graph merges many stack samples. Each box is a frame; its width is the share of samples that contain it; a box sits on top of its caller. Representative excerpt in collapsed-stack form, the text format flame graphs are drawn from (not captured here):
java/lang/Thread.run;Engine.loop;OrderBook.match;TreeMap.getEntry 4210
java/lang/Thread.run;Engine.loop;OrderBook.match;Fill.<init> 980
java/lang/Thread.run;Engine.loop;Json.encode;StringBuilder.append 2630
java/lang/Thread.run;Engine.loop;Json.encode;Arrays.copyOf 1400Reading rules:
- Look for wide boxes near the top: the plateaus are where samples end, so that frame itself used the CPU. Here
TreeMap.getEntryand the JSON encoding dominate. - The x-axis is sorted alphabetically, not by time. Order between siblings means nothing.
- In an
allocgraph, width is bytes allocated, and the top frame is the allocation site (Arrays.copyOffrom a growingStringBuilder). - In a
lockgraph, width is time spent waiting for a lock, and the stack shows who waited, not who held it. - A
wallgraph includes sleeping and blocked threads, so idle pool threads appear as wideparkframes; filter by thread (-t) to find the request thread.
Interview points
- JFR is built into HotSpot, records events into a ring buffer with low overhead, and is meant to run in production.
- Custom events extend
jdk.jfr.Event, usebegin,end,shouldCommit, andcommit, and are enabled by name. - Thresholds drop short events; that keeps lock and I/O events cheap.
RecordingFilereads a dump;RecordingStreamdelivers events live to callbacks.- Safepoint bias makes safepoint-based profilers attribute time to poll locations; async-profiler and JFR's execution sampler avoid it.
- Flame graph width is sample share; the x-axis is not time; the top edge is where the cost is.
cpushows on-CPU time;wallshows blocked time too;allocshows bytes;lockshows contention.
Summary
-XX:StartFlightRecordingandjcmd JFR.start/dump/stopcontrol recordings without code.Recording.enable(name)turns on an event type;withThresholdandwithPeriodconfigure it.@Name,@Label,@Category, and@StackTracedescribe custom events.RecordedEventexposes fields by name (getLong,getString), start time, duration, and thread.- async-profiler samples via signals and
perf_events, including native and kernel frames. - A flame graph is merged stack samples; plateaus at the top are the hot code.
