Fast, modern JFR (Java Flight Recorder) parser for the JVM with a small, focused API.
Status: Early public release (v0.11.0) - API may evolve based on feedback. See CHANGELOG.md for details.
JAFAR provides both typed (interface-based) and untyped (Map-based) APIs for parsing JFR recordings with minimal ceremony. It emphasizes performance, low allocation, and ease of use.
- Java 21+
- Git LFS (recordings are stored with LFS). Install per GitHub docs:
https://docs.github.com/en/repositories/working-with-files/managing-large-files/installing-git-large-file-storage
- Fetch binary resources:
./get_resources.sh - Build all modules:
./gradlew shadowJar
Define a Java interface per JFR type and annotate with @JfrType. Methods correspond to event fields; use @JfrField to map differing names and @JfrIgnore to skip fields.
import io.jafar.parser.api.*;
import java.nio.file.Paths;
@JfrType("custom.MyEvent")
public interface MyEvent { // no base interface required
String myfield();
}
try (TypedJafarParser p = JafarParser.newTypedParser(Paths.get("/path/to/recording.jfr"))) {
HandlerRegistration<MyEvent> reg = p.handle(MyEvent.class, (e, ctl) -> {
System.out.println(e.myfield());
long pos = ctl.stream().position(); // current byte position while in handler
// ctl.abort(); // optionally stop parsing immediately without throwing
});
p.run();
reg.destroy(p); // deregister
}Notes:
- Handlers run synchronously on the parser thread. Keep work small or offload.
- Exceptions thrown from a handler stop parsing and propagate from
run(). - Call
ctl.abort()inside a handler to stop parsing early without an exception.
Receive events as Map<String, Object> with nested maps/arrays when applicable.
import io.jafar.parser.api.*;
import java.nio.file.Paths;
try (UntypedJafarParser p = JafarParser.newUntypedParser(Paths.get("/path/to/recording.jfr"))) {
HandlerRegistration<?> reg = p.handle((type, value) -> {
if ("jdk.ExecutionSample".equals(type.getName())) {
// You can retrieve the value by providing 'path' -> "eventThread", "javaThreadId"
Object threadId = Values.get(value, "eventThread", "javaThreadId");
// You can also get the value conveniently typed - for primitive values you need to use the boxed type in the call
long threadIdLong = Values.as(value, Long.class, "eventThread", "javaThreadId");
// use threadId ...
}
});
p.run();
reg.destroy(p);
}- ComplexType: Complex fields may appear either inline as
Map<String, Object>or as a wrapper implementingio.jafar.parser.api.ComplexType(e.g., constant-pool backed references). UsegetValue()on aComplexTypeto obtain the resolvedMap<String, Object>. - ArrayType: When a field is an array, the value implements
io.jafar.parser.api.ArrayType. UsegetType()to inspect the array class (e.g.,int[].class,Object[].class) andgetArray()to access the underlying Java array.
Examples:
import io.jafar.parser.api.*;
import java.util.Map;
try (UntypedJafarParser p = JafarParser.newUntypedParser(Paths.get("/path/to/recording.jfr"))) {
p.handle((type, value) -> {
// ComplexType: constant-pool backed references (e.g., eventThread)
Map<String, Object> thread = Values.as(value, Map.class, "eventThread").orElse(null);
if (thread != null) {
System.out.println("thread id=" + thread.get("javaThreadId") + ", name=" + thread.get("name"));
}
// ArrayType: arrays of primitives, Strings, maps, or ComplexType elements
Object framesVal = Values.get(value, "stackTrace", "frames");
// You can also reference the array elements directly
Object firstFrame = Values.get(value, "stackTrace", "frames", 0);
if (framesVal instanceof ArrayType at) {
Object arr = at.getArray();
if (arr instanceof Object[] objs) {
for (Object el : objs) {
if (el instanceof ComplexType cpx) {
Map<String, Object> m = cpx.getValue();
// use fields from the resolved element
} else if (el instanceof Map) {
Map<String, Object> m = (Map<String, Object>) el; // inline complex value
} else {
// primitive wrapper or String
}
}
} else if (arr instanceof int[] ints) {
for (int i : ints) { /* ... */ }
} else if (arr instanceof long[] longs) {
for (long l : longs) { /* ... */ }
}
}
});
p.run();
}JAFAR now supports build-time handler generation via annotation processor, providing massive performance benefits for production applications.
Benchmark Results:
- 85% less memory allocation (35.5 MB/sec vs 237.2 MB/sec)
- Eliminates GC collections (0 vs 3 GC pauses per benchmark)
- Equivalent throughput (no performance penalty)
- Predictable latency (no GC jitter)
-
Compile-time: Annotation processor scans
@JfrTypeinterfaces and generates:- Handler implementation classes
- Factory classes with thread-local caching
- ServiceLoader registration (META-INF/services)
-
Runtime: Parser auto-discovers factories via ServiceLoader, handlers are reused via thread-local cache
Gradle:
dependencies {
implementation 'io.btrace:jafar-parser:0.11.0'
annotationProcessor 'io.btrace:jafar-processor:0.11.0'
}Maven:
<dependencies>
<dependency>
<groupId>io.btrace</groupId>
<artifactId>jafar-parser</artifactId>
<version>0.11.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>io.btrace</groupId>
<artifactId>jafar-processor</artifactId>
<version>0.11.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>// Must be top-level interfaces (not nested/inner classes)
@JfrType("jdk.ExecutionSample")
public interface JFRExecutionSample {
@JfrField("startTime")
long startTime();
@JfrField("sampledThread")
JFRThread sampledThread();
}
@JfrType("java.lang.Thread")
public interface JFRThread {
@JfrField("javaThreadId")
long javaThreadId();
@JfrField("javaName")
String javaName();
}Note: Annotation processor only processes top-level interfaces. Nested/inner classes with @JfrType are skipped and use runtime generation instead.
try (TypedJafarParser p = JafarParser.newTypedParser(Paths.get("/path/to/recording.jfr"))) {
// Factories automatically discovered via ServiceLoader - no registration needed!
// Handle events (uses thread-local cached handlers)
p.handle(JFRExecutionSample.class, (event, ctl) -> {
JFRThread thread = event.sampledThread();
if (thread != null) {
System.out.println("Thread: " + thread.javaName());
}
});
p.run();
}That's it! The annotation processor generates factories and registers them via ServiceLoader. No manual registration required.
If you don't register factories, JAFAR falls back to runtime bytecode generation (existing behavior):
try (TypedJafarParser p = JafarParser.newTypedParser(Paths.get("/path/to/recording.jfr"))) {
// No factory registration - handlers generated at runtime via ASM
p.handle(JFRExecutionSample.class, (event, ctl) -> {
// Handler generated on first use, cached globally
System.out.println("Event: " + event.startTime());
});
p.run();
}✅ Use build-time generation when:
- Processing large JFR files or streams (millions of events)
- Memory allocation is a bottleneck
- Running in memory-constrained environments (containers)
- GC pauses affect latency SLAs
- Deploying to GraalVM native images
- Event types are known at compile time
✅ Use runtime generation when:
- Building JFR analysis tools (unknown event types)
- Rapid prototyping and exploration
- Processing arbitrary JFR recordings
- No build-time configuration desired
For processing 1 million ExecutionSample events:
| Metric | Runtime Generation | Build-Time Generation | Benefit |
|---|---|---|---|
| Total Allocations | ~223 GB | ~37 GB | -186 GB |
| GC Collections | ~600-800 | ~50-100 | -750 GC pauses |
| GC Pause Time | ~2-3 seconds | ~200-300ms | -2.7 seconds |
| Throughput | ~189k events/sec | ~187k events/sec | Equivalent |
For the architecture of the parser module, see the Parser Architecture.
JafarParsernewTypedParser(Path)/newUntypedParser(Path): start a session.withParserListener(ChunkParserListener): observe low-level parse events (advanced, see below).run(): parse and invoke registered handlers.
TypedJafarParserhandle(Class<T>, JFRHandler<T>) -> HandlerRegistration<T>- Static
open(String|Path[, ParsingContext])are also available, but preferJafarParser.newTypedParser(Path).
UntypedJafarParserhandle(UntypedJafarParser.EventHandler) -> HandlerRegistration<?>- Static
open(String|Path[, ParsingContext])also available.
- Data wrappers
ArrayType: wrapper around arrays.getType()returns the array class;getArray()returns the backing Java array.ComplexType: wrapper around complex values.getValue()resolves to aMap<String, Object>. Note that some complex fields may be provided inline as aMapwithout a wrapper.
ParsingContextcreate(): build a reusable context.newTypedParser(Path)/newUntypedParser(Path): create parsers bound to the shared context.uptime(): cumulative processing time across sessions using the context.
Controlstream().position(): current byte position while a handler executes.abort(): stop parsing immediately (no exception thrown).chunkInfo(): chunk metadata withstartTime(),duration(),size(), andconvertTicks(long, TimeUnit).- Why
convertTicks(...)? JFR records many time values in chunk-relative ticks. Converting on demand avoids creatingInstant/Durationobjects for every event, minimizing allocation and GC pressure when a scalar value suffices. Convert only when needed and to the unit you need.
- Why
- The typed parser defines small, generated classes at runtime. It automatically picks the best available strategy for the running JDK:
- JDK 15+: hidden classes via
MethodHandles.Lookup#defineHiddenClass(fastest, unloadable) - JDK 9–14:
MethodHandles.Lookup#defineClass(byte[])(good) - JDK 8:
sun.misc.Unsafe#defineAnonymousClass(compatible; slightly heavier)
- JDK 15+: hidden classes via
- Selection is automatic based on capability probes; no flags required. Enable debug logs to see the chosen strategy.
- The
parserartifact is a Multi‑Release JAR:- Base classes target Java 8 for broad compatibility.
- Java 21 overrides live under
META-INF/versions/21and restore faster implementations (e.g., zero‑copyByteBufferslicing,Arrays.equalsrange checks,Files.writeString, etc.).
- On Java 21+, the JVM loads these optimized classes automatically. On older JVMs, the Java 8 fallbacks are used.
- Annotations
@JfrType("<fq.type>"): declare the JFR type an interface represents.@JfrField("<jfrField>", raw = false): map differing names or request raw representation.@JfrIgnore: exclude a method from mapping.
- Reusing context across many recordings
ParsingContext ctx = ParsingContext.create();
try (TypedJafarParser p = ctx.newTypedParser(Paths.get("/path/to.a.jfr"))) {
p.handle(MyEvent.class, (e, ctl) -> {/*...*/});
p.run();
}
try (TypedJafarParser p = ctx.newTypedParser(Paths.get("/path/to.b.jfr"))) {
p.handle(MyEvent.class, (e, ctl) -> {/*...*/});
p.run();
}
System.out.println("uptime(ns)=" + ctx.uptime());- Early termination with Control
AtomicInteger seen = new AtomicInteger();
try (TypedJafarParser p = JafarParser.newTypedParser(Paths.get("/path/to.jfr"))) {
HandlerRegistration<JFRJdkExecutionSample> reg =
p.handle(JFRJdkExecutionSample.class, (e, ctl) -> {
if (seen.incrementAndGet() >= 1000) {
ctl.abort(); // stop without throwing
}
});
p.run();
reg.destroy(p);
}- Converting JFR ticks to time units
// Some fields are expressed in JFR ticks. Convert them only when needed.
try (UntypedJafarParser p = JafarParser.newUntypedParser(Paths.get("/file.jfr"))) {
p.handle((type, value, ctl) -> {
long ticksObj = value.get("startTime"); // example field holding ticks
long nanos = ctl.chunkInfo().convertTicks(n.longValue(), TimeUnit.NANOSECONDS);
// Use nanos directly, or wrap as Instant only when necessary
Instant startTs = ctl.chunkInfo().startTime().plusNanos(nanos);
// use the startTs instant ...
});
p.run();
}- Observing parse lifecycle (low-level)
import io.jafar.parser.internal_api.ChunkParserListener;
import io.jafar.parser.internal_api.metadata.MetadataEvent;
p.withParserListener(new ChunkParserListener() {
@Override public boolean onMetadata(ParserContext c, MetadataEvent md) {
// inspect metadata per chunk
return true; // continue
}
}).run();Plugin id: io.btrace.jafar-gradle-plugin
Adds task generateJafarTypes and wires it to compileJava. It can generate interfaces for selected JFR types from either the current JVM metadata (default) or a .jfr file.
Generated class naming: The generator includes the full namespace in generated interface names to avoid collisions. For example:
jdk.ExecutionSample→JFRJdkExecutionSampledatadog.ExecutionSample→JFRDatadogExecutionSamplejdk.gc.HeapSummary→JFRJdkGcHeapSummary
This ensures that events with the same simple name but different namespaces generate distinct interfaces.
plugins {
id 'io.btrace.jafar-gradle-plugin' version '0.11.0'
}
repositories {
mavenCentral()
mavenLocal()
maven { url "https://oss.sonatype.org/content/repositories/snapshots/" }
}
generateJafarTypes {
// Optional: use a JFR file to derive metadata; otherwise JVM runtime metadata is used
inputFile = file('/path/to/recording.jfr')
// Optional: where to generate sources (default: build/generated/sources/jafar/src/main)
outputDir = project.file('src/main/java')
// Optional: do not overwrite existing files (default: false)
overwrite = false
// Optional: filter event types by name (closure gets fully-qualified JFR type name)
eventTypeFilter {
it.startsWith('jdk.') && it != 'jdk.SomeExcludedType'
}
// Package for generated interfaces (default: io.jafar.parser.api.types)
targetPackage = 'com.acme.jfr.types'
}You can also provide the input file via a project property: -Pjafar.input=/path/to/recording.jfr.
io.jafar.tools.Scrubber can scrub selected string fields in-place while copying to a new file.
Example: scrub the value of jdk.InitialSystemProperty.value when key == 'java.home'.
import static io.jafar.tools.Scrubber.scrubFile;
import io.jafar.tools.Scrubber.ScrubField;
import java.nio.file.Paths;
scrubFile(Paths.get("/in.jfr"), Paths.get("/out-scrubbed.jfr"),
clz -> {
if (clz.equals("jdk.InitialSystemProperty")) {
return new ScrubField("key", "value", (k, v) -> "java.home".equals(k));
}
return null; // no scrubbing for other classes
}
);Build and run the demo application:
# First you need to publish parser, tools and plugin to local maven
cd demo
./build.sh
java -jar build/libs/jafar-demo-all.jar [jafar|jmc|jfr|jfr-stream] /path/to/recording.jfrOn an M1 and a ~600MiB JFR, the Jafar parser completes in ~1s vs ~7s with JMC (anecdotal). The stock jfr tool may OOM when printing all events.
JAFAR includes jfr-shell, an interactive CLI for exploring and analyzing JFR files with a powerful query language. See jfr-shell/README.md for features and installation.
- Interactive REPL with intelligent tab completion
- JfrPath query language for filtering, projection, and aggregation
- Scripting support: record, save, and replay analysis workflows with variable substitution ⭐ NEW
- Event decoration for correlating and joining events (time-based and key-based)
- Multiple output formats: table and JSON
- Multi-session support: work with multiple recordings simultaneously
- Non-interactive mode: execute queries from command line for scripting/CI
# Install via JBang (easiest)
jbang app install jfr-shell@btraceio
# Open and analyze a recording
jfr-shell recording.jfr
jfr> events/jdk.ExecutionSample | groupBy(thread/name)
jfr> events/jdk.FileRead | top(10, by=bytes)
# Event decoration: correlate samples with lock waits
jfr> events/jdk.ExecutionSample | decorateByTime(jdk.JavaMonitorWait, fields=monitorClass)See Event Decoration and Joining for advanced correlation and joining capabilities.
JAFAR includes an MCP (Model Context Protocol) server that enables AI agents like Claude to analyze JFR recordings. See jfr-mcp/README.md for details.
curl -Ls https://raw.githubusercontent.com/btraceio/jafar/main/jfr-mcp/install.sh | bashThis installs JBang (if needed) and the jfr-mcp command in one step.
claude mcp add jafar -- jbang jfr-mcp@btraceio --stdio{
"mcpServers": {
"jafar": {
"command": "jbang",
"args": ["jfr-mcp@btraceio", "--stdio"]
}
}
}- jfr-shell/README.md - Interactive JFR analysis tool
- doc/cli/Architecture.md - Architecture overview with diagrams
- doc/cli/Tutorial.md - Complete JFR Shell tutorial with event decoration
- doc/cli/Scripting.md - Scripting guide: automate analysis workflows ⭐ NEW
- doc/cli/ScriptExecution.md - Script execution tutorial ⭐ NEW
- doc/cli/CommandRecording.md - Command recording tutorial ⭐ NEW
- doc/cli/JFRPath.md - JfrPath query language reference
- doc/cli/Backends.md - Backend plugin guide and TCK (Technology Compatibility Kit)
- doc/cli/BackendQuickstart.md - Build a custom backend in 10 minutes
- jfr-mcp/README.md - MCP server overview and quick install
- doc/mcp/Tutorial.md - Full MCP server tutorial
- doc/mcp/JBANGUsage.md - JBang installation and usage
- CHANGELOG.md - Version history and release notes
- LIMITATIONS.md - Known limitations and workarounds
- PERFORMANCE.md - Performance benchmarks and tuning tips
- CONTRIBUTING.md - How to contribute to JAFAR
- SECURITY.md - Security policy and vulnerability reporting
Contributions are welcome! Please read CONTRIBUTING.md for guidelines.
To report security vulnerabilities, see SECURITY.md (do not create public issues).
Apache 2.0 (see LICENSE).