Java Flight Recorder File Format

Intro

Java Flight Recorder 简称 JFR,OpenJDK 从 11 版本开始支持。它是一个低开销的数据收集框架,可用于在生产环境中分析 Java 应用和 JVM 运行状况及性能问题。

故障诊断、监控和profile收集分析是开发周期中不可缺少的一部分。但是很多问题都只会在高负载的生产环境中产生。此时就需要一个可以在生产环境中使用的监控工具,JFR由此而生。

JFR会从应用程序中记录运行时事件,同时也会记录JVM和OS的。记录的结果会存在一个单独的文件中,此文件可供开发工程师分析bug和性能问题。

同时JDK中也提供了可视化工具(指 JMC - JDK Mission Control)来分析这类文件。

我们可以用 jdk 原生的 jfr 来采集一个 Java 进程的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ jfr
Tool for working with Flight Recorder files (.jfr)

Before using this tool, you must have a recording file.
A file can be created by starting a recording from command line:

java -XX:StartFlightRecording:filename=recording.jfr,duration=30s ...

A recording can also be started on already running Java Virtual Machine:

jcmd (to list available pids)
jcmd <pid> JFR.start

Recording data can be dumped to file using the JFR.dump command:

jcmd <pid> JFR.dump filename=recording.jfr

The contents of the recording can then be printed, for example:

jfr print recording.jfr

也可以用一个第三方(?)的开源实现来获取 jfr 文件:https://github.com/async-profiler/async-profiler

Problems

用上面的工具获取了一段 jfr 监控数据后,我们来看看文件本身:

image-20231024133310436

如果使用

1
jfr print --json out-2023-06-14_15_16_16.jfr > jfr_details.json

来获取它的完整信息的话,我们将获得一个上百倍大小的 json 文件。

image-20231024133524895

如果对这个 json 文件做简单的 zip 压缩,获得的文件也有 jfr 的三倍大:

image-20231024133836899

做成 7z 倒是会更小,但是这种压缩策略不适合流式的事件输入,并且可能也不符合监控程序低 CPU 占用的要求(后续有待对比验证):

image-20231024134007006

所以我们很好奇 jfr 文件本身采用了什么技术来让压缩又快又好。

然鹅,jfr 本身并没有任何关于文件格式的开源文档,官方的解释是经常变动,不方便维护解释。于是我们只能从 jdk / jmc 的开源代码里去一探究竟。

File Analysis

Overview

image-20231024142038607

在一个 .jfr 文件中,起始位置为 File Header,包括了Magic Number, Checkpoint Offset, Metadata Offset, Start Time, Duration… 等信息。

中间的 Chunks 则是以存储大小切割,每 12 MB 切一个 Chunk,每个 Chunk 内存储了经过编码的 Events,而 Event 就是监控的最基本单元。

最后的文件末尾存储了两个特殊的 Event(虽然我不知道他们为什么叫 Event),Checkpoint Event 内存储的基本上是各个类型的常量池,用常量池中对应的 long index 替换实际的量是压缩的重点之一。Metadata Event 内则定义了 Event 的类型、一些属性的结构等等,用以正确地解析 Event 中紧凑的编码。

一个 Recording 对应一整个 .jfr 文件,这里我主要通过 jmc 的源码来看出 https://github.com/openjdk/jmc/blob/0d8b09d602f306f65f577ccda8ef2abe120cf738/core/org.openjdk.jmc.flightrecorder.writer/src/main/java/org/openjdk/jmc/flightrecorder/writer/RecordingImpl.java

直接看代码,非常明白。

1
2
3
4
5
6
7
8
9
10
private void writeFileHeader() {
globalWriter.writeBytes(MAGIC).writeShortRaw(MAJOR_VERSION).writeShortRaw(MINOR_VERSION).writeLongRaw(0L) // size placeholder
.writeLongRaw(0L) // CP event offset
.writeLongRaw(0L) // meta event offset
.writeLongRaw(startNanos) // start time in nanoseconds
.writeLongRaw(0L) // duration placeholder
.writeLongRaw(startTicks) // start time in ticks
.writeLongRaw(1_000_000_000L) // 1 tick = 1 ns
.writeIntRaw(1); // use compressed integers
}

Constant Pool offset 即 Checkpoint Event 的 offset,和 Metadata Event offset 一样,在整个 Recording 采集完毕后更新,因为它俩 Event 写在文件末尾。在采集完毕之前,它们的数据保存在内存中。

Constant Pool

image-20231024152217458

String 和一些自定义类型,每次有新的值就加入常量池。新增常量时,对应的 index 递增(根据 size + 1)。reverseMap 用于将 index 反向索引到常量,方便最后 flush 到文件。

对于像 Event 里典型的 Stack Trace 类型数据,jfr 也保存了常量池,但是这里面会有很多重复的 String,比如两个不同的 Stack Trace 的前几层是一样的。这里其实可以在源码里发现,Stack Trace 的常量池的常量内部依然可以使用别的常量如 String,到头来相同的 String 还是只会被保存一次。

https://github.com/openjdk/jmc/blob/0d8b09d602f306f65f577ccda8ef2abe120cf738/core/org.openjdk.jmc.flightrecorder.writer/src/main/java/org/openjdk/jmc/flightrecorder/writer/ConstantPool.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void writeValueType(LEB128Writer writer, TypedValueImpl typedValue, boolean useConstantPoolReferences) {
if (typedValue == null) {
throw new NullPointerException();
}
TypeImpl type = typedValue.getType();
if (type.isBuiltin()) {
writeBuiltinType(writer, typedValue, useConstantPoolReferences);
} else {
if (typedValue.isNull()) {
writer.writeLong(0); // null value encoding
} else {
if (useConstantPoolReferences) {
// only if cp-refs are allowed
writer.writeLong(typedValue.getConstantPoolIndex());
} else {
for (TypedFieldValueImpl fieldValue : typedValue.getFieldValues()) {
TypedField field = fieldValue.getField();
if (field.isArray()) {
writer.writeInt(fieldValue.getValues().length); // array length
for (TypedValueImpl t : fieldValue.getValues()) {
writeValueType(writer, t, t.getType().hasConstantPool());
}
} else {
writeValueType(writer, fieldValue.getValue(), field.getType().hasConstantPool());
}
}
}
}
}
}

写入文件的过程也非常朴素:

1
2
3
4
5
6
7
8
void writeTo(LEB128Writer writer) {
writer.writeLong(type.getId()); // CP type ID
writer.writeInt(constantMap.size()); // number of constants
reverseMap.forEach((k, v) -> {
writer.writeLong(k); // constant index
writeValueType(writer, v, false);
});
}

Metadata

这个就比较杂乱,参考了大佬的文章随便看看:

image-20231024153511769

为啥这里有一堆 String 的常量嘞?这些主要是一些类型名属性名等等,而上面的常量池存的是属性值的常量。

下面那个框里的就是一些结构定义,记录一个类型里面有哪些数据,方便解析:

image-20231024153857235

LEB128 Encoding

在存储的数据中,实际上接近 Integer.MAX_VALUE 的值是很少的,大部分数据都很小,可以用更少的字节来完成存储。

而像 Java 中,Integer 固定是 4 字节,Long 固定是 8 字节。LEB128 编码使用每个字节里的 7 个比特来存储实际数据,用开头的第一个比特作为标记,表征一个数是否编码结束。也就是一种动态长度编码。jfr 内基本所有整形值(包括所有的常量池替换 index)都使用了 LEB128 编码。

1 _ _ _ _ _ _ _ -> 有更多后续字节

0 _ _ _ _ _ _ _ -> 结束

以十进制的 10000 为例,使用标准的 int,我们将获得:

0000 0000 0000 0000 0010 0111 0001 0000

使用 LEB128,转换为:

1100 1110 0001 0000

缩小了一半。

当然,在细节上 jfr 稍有不同,jfr 内对于整形值使用的最大类型是 long,long 最多是 64 bit = 7 * 8 + 8,小于 56 bit 时使用标准的 LEB128 编码,当达到最后一个字节(第九个)时,直接原样保存这个字节。

https://github.com/openjdk/jmc/blob/0d8b09d602f306f65f577ccda8ef2abe120cf738/core/org.openjdk.jmc.flightrecorder.writer/src/main/java/org/openjdk/jmc/flightrecorder/writer/AbstractLEB128Writer.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Override
public final long writeLong(long offset, long data) {
if ((data & LEB128Writer.COMPRESSED_INT_MASK) == 0) {
return writeByte(offset, (byte) (data & 0xff));
}
offset = writeByte(offset, (byte) (data | LEB128Writer.EXT_BIT));
data >>= 7;
if ((data & LEB128Writer.COMPRESSED_INT_MASK) == 0) {
return writeByte(offset, (byte) data);
}
offset = writeByte(offset, (byte) (data | LEB128Writer.EXT_BIT));
data >>= 7;
if ((data & LEB128Writer.COMPRESSED_INT_MASK) == 0) {
return writeByte(offset, (byte) data);
}
offset = writeByte(offset, (byte) (data | LEB128Writer.EXT_BIT));
data >>= 7;
if ((data & LEB128Writer.COMPRESSED_INT_MASK) == 0) {
return writeByte(offset, (byte) data);
}
offset = writeByte(offset, (byte) (data | LEB128Writer.EXT_BIT));
data >>= 7;
if ((data & LEB128Writer.COMPRESSED_INT_MASK) == 0) {
return writeByte(offset, (byte) data);
}
offset = writeByte(offset, (byte) (data | LEB128Writer.EXT_BIT));
data >>= 7;
if ((data & LEB128Writer.COMPRESSED_INT_MASK) == 0) {
return writeByte(offset, (byte) data);
}
offset = writeByte(offset, (byte) (data | LEB128Writer.EXT_BIT));
data >>= 7;
if ((data & LEB128Writer.COMPRESSED_INT_MASK) == 0) {
return writeByte(offset, (byte) data);
}
offset = writeByte(offset, (byte) (data | LEB128Writer.EXT_BIT));
data >>= 7;
if ((data & LEB128Writer.COMPRESSED_INT_MASK) == 0) {
return writeByte(offset, (byte) data);
}
offset = writeByte(offset, (byte) (data | LEB128Writer.EXT_BIT));
return writeByte(offset, (byte) (data >> 7));
}

这里面有一大坨重复代码,但他没写成 for)。

其余的 int short 都转换成 long 来用同一方法编码,反正是变长的编码。

1
2
3
4
@Override
public final long writeInt(long offset, int data) {
return writeLong(offset, data & 0x00000000ffffffffL);
}

References

The JDK Flight Recorder File Format - Gunnar Morling

jmc/core/org.openjdk.jmc.flightrecorder.writer at master · openjdk/jmc · GitHub