深度探索JFR - JFR详细介绍与生产问题定位落地 - 3.
本文基于 OpenJDK 11
3. 虚拟机相关 Event
3.3. JIT即时编译相关
JIT 即时编译可能会遇到编译后的代码缓存占满,或者因为空间有限或者代码设计问题,导致某些关键方法需要重编译导致性能问题,以及因为代码块过大导致编译失败从而性能有问题,这些问题我们可以通过 JFR 中相关的 Event 进行查询。
JFR 对于 Java 开发可以完全替换 JVM 编译日志。
额外讲解:JIT 相关的知识
首先,这里简单介绍下 JIT 相关的知识(这里我推荐看 O'Rerilly 上面的 Java Performance 第二版的第四章:Working with the JIT Compiler):
首先什么是 JIT: 当 Java 被编译为字节码形式的 class 文件之后,他可以在任意的 JVM 运行。这里说的编译,主要是指前端编译器。但是 class 文件里面的字节码并不能直接运行,而是要通过后端编译器在程序运行期间,将字节码转变成机器码,这样电脑才能执行你的代码。
Java Code Cache 是啥: 如果 Java 每次都需要即时编译成机器码,再执行,效率太慢了。那么是不是对于某些热点代码,编译后的机器码,缓存起来,这样下次就不用重新即时编译了,多快乐。Java Code Cache 就是用来干这个的。但是编译后的机器码太大了,Java Code Cache 的空间是有限的,也不能将所有的代码都编译成机器码缓存起来。所以就需要一些优化与清理策略。
C1,C2 编译器与分层编译(Tiered Compilation)以及编译级别(Compilation Level): C1,C2 按照老概念来看,分别是客户端编译器和服务端编译器,随着 -server 的 Java 参数的消失,代表着 Java 程序本质上不再区分客户端服务端,所以目前所有的 Java 进程都是 C1,C2混合使用。
C1 编译器对代码做一些简单的优化并加入一些采样代码, C2 针对 C1 加入的代码的采样结果,做更多的分析(语法解析,逃逸分析,高级优化器等等),优化成为更好的代码。C1是一个简单快速的编译器,主要关注点在于局部优化,而放弃许多耗时较长的全局优化手段。C2 则是关注于耗时较长的全局优化手段。同时由于 Java Code Cache 是有限的,有些代码可能优化后被淘汰,需要重新编译,没必要对于每种代码都进行 C1 C2 的优化,那些执行次数少的代码,直接编译执行即可。
C1,C2是怎么配合的呢?从 Java 7 开始引入了分层编译的概念,目前已经是默认的即时编译方式。Java 代码一共有如下几层编译级别:
- level 0:代码直接解释执行
- level 1:通过 C1 编译器优化编译执行(不会加入采样代码,例如统计这段代码执行次数等等)
- level 2:通过 C1 编译器优化编译执行(加入非常轻量的采样代码)
- level 3:通过 C1 编译器优化编译执行(加入所有的采样代码)
- level 4:通过 C2 编译器优化编译执行(根据上一步采样代码的信息,决定如何优化)
一般情况下,热点代码都是经过 0 -> 3 -> 4 这个路径被优化的。但是也有例外情况:
- 当方法过于简单的时候(例如只有一行代码),C2 不能在 C1 的基础上更加优化,则走向 level 1,去掉了采样代码。
- 如果 C2 线程正忙(C2 线程任务队列满了),则走向 level 2,因为认为 C2 一段时间内不会恢复,level 3 采集的很多数据有时效性,现在采集也没意义,所以先走向 level 2 减少采集。之后过一段时间,重新变成 level 3,等待 C2 闲时,走向 level 4
- C1 忙,但是 C2 闲时,直接从 level 0 升级到 level 4。
Java Code Cache 分块(Segmented Code Cache): 从 Java 9 开始引入的 Code Cache 分块,主要解决之前把所有种类代码放一起,导致扫描的时候效率低下。例如之前说的有些代码经过 C1 优化,之后 C2 优化,这些代码最好分开存储。(C1 优化过得代码,C2 优化完之后,C1的要被清理掉)。
目前 Java Code Cache 分为 3 块:
- 非方法代码堆(non-method code heap):这些是 JIT 编译器要用到的内存区域,例如编译器要用的缓存等等。他们会永远存在于 Code Cache 内。
- 带采样的代码堆(profiled code heap):目前是经过 C1 编译器优化的代码,就是轻度优化,带采样代码的代码,存活时间比较短,因为 C1 优化的代码最终会被 C2 优化,或者退回 level 1 去掉采样。
- 不带采样的代码堆(non-profiled code heap):包含不带采样的代码,已经完全优化好,并且长期存活的代码。
默认情况下,非方法代码堆包括 3MB 的来自于虚拟机的固定空间占用,以及随着编译线程个数上涨而上涨的空间占用。剩下的空间,带采样的代码堆与不带采样的代码堆评分,可以通过如下参数修改:
- -XX:NonProfiledCodeHeapSize: 不带采样的代码堆大小.
- -XX:ProfiledCodeHeapSize: 带采样的代码堆大小.
- -XX:NonNMethodCodeHeapSize: 非方法代码堆大小.
- -XX:ReservedCodeCacheSize 以上三个加起来需要等于这个
Code Cache Sweeper(代码缓存清理器): 随着 Java Code Cache 的分块处理,目前 Code Cache Sweeper 只用扫描 带采样的代码堆 和 非方法代码堆,并且可以分开并发扫描,看那些代码缓存可以回收。
Method Inline(方法内联): 通俗来讲,就是JVM在运行时优化编译好的代码,将经常调用的方法从调用替换为方法体代码,减少调用。通常发生在 C1 编译器优化。举一个简单的例子:在编写Java的POJO object时候,经常会用到getter和setter,这是从学校里学java开始,我们就一直使用的设计模式,为了保证POJO类对象的域安全。但是我们是否考虑过,如果每次都用这个而外的方法修改,那么是否都会多一次方法栈寻址调用?这样对性能肯定是有影响的。幸好我们有Java Inline Method这个机制,参考下面的代码:
public class JitInlining {
public static void main(String args[]) {
Position position = new Position(25);
for (int i = 0; i < 1000000; ++i) {
int x = position.getX();
position.setX(x + 1);
}
System.out.println(position.getX());
}
}
class Position {
private int x;
public Position(int x) {
this.x = x;
}
public int getX() {
return x;
}
public void setX(int x) {
this.x = x;
}
}
加上jvm参数来看一下inline效果:-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining
@ 19 com.test.Position::getX (5 bytes) accessor
@ 27 com.test.Position::setX (6 bytes) accessor
这个accessor代表jit识别这些方法是某个field的accessor(即getter或者setter),accessor是会被inline的。
OSR(On Stack Replacement): 一种在运行时替换正在运行的函数/方法的栈帧的技术。JIT 优化,一般在某一段代码被执行很多次之后,例如:
for (int i = 0; i < 10000; i ++) {
this.data++;
}
this.data++;
如果被编译器优化之后,这个循环还没执行完,则需要 OSR 机制,动态替换正在栈帧中待执行的代码。这样还没完成的循环,就可以用优化后的代码执行。
通常来看,应该把 所有的栈上替换执行代码都叫做 OSR,但是 Java 中只针对于编译器优化的代码替换原有代码称为 OSR,对于去优化的替换(就是用原有代码替换优化过的代码),称为Bailout
为什么会有这种Bailout的情况呢?代码优化可以分为(这里参考大神的知乎回答):
- 从低优化向高优化迁移:为了平衡启动性能(刚开始要启动速度快,所以要初始开销小的执行模式)和顶峰性能(代码执行一段时间,对于热点代码,想要更快需要更优化的执行模式,即便优化需要较大开销)
- 从高优化向低优化迁移:有很多情况,这里仅举例:
- 高优化层级做了很激进的优化:例如假设某个类不会有别的子类、某个引用一定不是null、某个引用所指向的对象一定是某个具体类型,等),而这个激进的假设假如失效了的话,就必须退回到没有做这些优化的“安全”的低优化层级去继续执行。这个非常重要,有了这种Bailout机制的支持,JIT编译器就可以对代码做非常激进的优化,性能受正确性要求的约束会得到放松,因而对常见代码模式可以生成更快的代码。
- 高优化层级不便于对代码做调试,如果某个方法之前已经被JIT优化编译了,而后来有调试器动态决定调试该方法,则让它从高优化层级退回到便于调试的低优化层级(例如解释器或者无优化的JIT编译版本的代码)去执行。
JIT即时编译相关的 Event 列表
事件名称 | 简介 | default.jfc中的配置 | profile.jfc中的配置 |
---|---|---|---|
CompilerConfiguration | 即时编译器配置采集 | 是否启用: 是;周期: 每写入一个新的 chunk 的开头采集一次 | 是否启用: 是;周期: 每写入一个新的 chunk 的开头采集一次 |
CompilerStatistics | 即时编译器统计数据采集 | 是否启用: 是;周期: 每写入一个新的 chunk 的开头采集一次 | 是否启用: 是;周期: 1000ms一次 |
Compilation | JIT编译事件收集,编译时间大于某一时间限制则被收集 | 是否启用: 是;时间阈值: 1s | 是否启用: 是;时间阈值: 100ms |
CompilierPhase | 编译阶段,较为底层的事件,一般 Java 开发不关心这个时间 | 是否启用: 是;时间阈值: 60s | 是否启用: 是;时间阈值: 10s |
CompilationFailure | 编译失败事件,default没有开启,可以考虑开启,但是里面的信息都比较底层,所以一般只看 Compilation 事件定位问题 | 是否启用: 否 | 是否启用: 是 |
CompilerInlining | 编译内联事件,这个事件线上不要开启,很影响性能,测试阶段可以开启看看内联失败的原因 | 是否启用: 否 | 是否启用: 否 |
CodeCacheConfiguration | 代码缓存配置采集 | 是否启用: 是;周期: 每写入一个新的 chunk 的开头采集一次 | 是否启用: 是;周期: 每写入一个新的 chunk 的开头采集一次 |
CodeCacheStatistics | 代码缓存统计数据采集 | 是否启用: 是;周期: 每一个 chunk 采集一次 | 是否启用: 是;周期: 每一个 chunk 采集一次 |
CodeCacheFull | 代码缓存空间已满事件 | 是否启用: 是 | 是否启用: 是 |
CodeSweeperConfiguration | 代码缓存清理配置采集 | 是否启用: 是;周期: 每写入一个新的 chunk 的开头采集一次 | 是否启用: 是;周期: 每写入一个新的 chunk 的开头采集一次 |
CodeSweeperStatistics | 代码缓存清理统计数据采集,这个我们一般不看的 | 是否启用: 是;周期: 每一个 chunk 采集一次 | 是否启用: 是;周期: 每一个 chunk 采集一次 |
SweepCodeCache | 代码缓存清理事件采集 | 是否启用: 是;时间阈值: 100ms | 是否启用: 是;时间阈值: 100ms |
CompilerConfiguration 事件结构
字段 | 描述 | 举例 |
---|---|---|
开始时间 | 事件开始时间 | 1594215268138540326 epochns |
Thread Count | 线程个数 | 2 |
Tiered Compilation | 是否启用了分层编译(Tiered Compilation) | true |
CompilerStatistics 事件结构
字段 | 描述 | 举例 |
---|---|---|
开始时间 | 事件开始时间 | 1594215268138540326 epochns |
Bailouts | 放弃编译好的机器码使用原有字节码执行的总次数(去优化,对应上面提到的 OSR 中的 Bailout 情况),这个如果经常发生,则需要看一下发生的事件对应的其他 Compiler 相关的事件是否能定位到对应的方法 | 2 |
Compiled Methods | 编译的方法总数 | 41329 |
Invalidated Compilations | 无效的编译 | 0 |
Compilation Resulting Code Size | 程序启动以来编译后的代码大小 | 85502368 B |
Compilation Resulting Size | 程序启动以来编译占用的总大小 | 128496488 B |
OSR Bytes Compiled | On Stack Replacement(栈上替换,这里是向上优化) 编译的代码字节大小,这个一般为采集不到,我们也不从这里看大小,一般为0,不用关注 | 0 B |
OSR Compilations | 程序启动以来 On Stack Replacement(栈上替换,这里是向上优化) 编译总次数 | 609 |
Peak Time | 程序启动以来最长编译时间 | 961 ms |
Standard Bytes Compiled | 标准编译代码大小,这个一般为采集不到,我们也不从这里看大小,一般为0,不用关注 | 0 B |
Standard Compilations | 程序启动以来标准编译次数 | 40108 |
Total time | JIT 编译总占用时间 | 206547 ms |
Compilation 事件结构
字段 | 描述 | 举例 |
---|---|---|
开始时间 | 事件开始时间 | 1594215268138540326 epochns |
持续时间 | 事件持续时间 | 55607927 ticks |
结束时间 | 事件结束时间 | 2869587482704980480 epochticks |
Event Thread | 编译线程,可能是 C1 CompilerThreadN 或者是 C2 CompilerThreadN,分别对应 C1,C2线程 | C1 CompilerThread0 |
Compiled Code Size | 编译后的代码大小 | 11048 B |
Compilation Identifier | 本次编译对应的全局唯一id,通过这个 id 确认哪些事件对应的是同一次编译。 | 5224 |
Compilation Level | 编译级别 1,2,3,4 (0不会出现的,因为就是没编译) | 1 |
Inlined Code Size | 内联代码大小 | 1328 B |
On Stack Replacement | 是否为栈上替换 | false |
Java Method | 对应的 Java 方法 | String java.io.WinNTFileSystem.resolve(File) |
Java Method Succeeded | 是否编译成功,如果没有成功,看看是否代码块太大导致的 | true |
CompilierPhase 事件我们并不关心,也不会打开,跳过。CompilationInlining 我们也是一般不看并且不会打开,这里也跳过。
CompilerFailure 事件结构
字段 | 描述 | 举例 |
---|---|---|
开始时间 | 事件开始时间 | 1594215268138540326 epochns |
Event Thread | 编译线程,可能是 C1 CompilerThreadN 或者是 C2 CompilerThreadN,分别对应 C1,C2线程 | C1 CompilerThread0 |
Compilation Identifier | 本次编译对应的全局唯一id,通过这个 id 确认哪些事件对应的是同一次编译。 | 6012 |
Failure Message | 失败信息 | Jvmti state change invalidated dependencies(JVM正在关闭会报这个) |
CodeCacheConfiguration 事件结构
字段 | 描述 | 举例 |
---|---|---|
开始时间 | 事件开始时间 | 1594215268138540326 epochns |
Expansion size | 每次扩展大小 | 65536 B |
Initial Size | 初始大小 | 2555904 B |
Minimum Block Length | 内存分配最小块大小 | 4 B |
Non-nmethod Size | 对应上面说的 非方法代码堆大小 | 5825164 B |
Non-profiled Size | 对应上面说的 不带采集代码堆大小 | 122916538 B |
Profiled Size | 对应上面说的 带采集代码堆大小 | 122916538 B |
Reserved Size | 总大小 | 251658240 B |
Reserved Top | 代码缓存内存堆地址 | 140692249706496 |
Start Address | 代码缓存内存堆基地址 | 140691998048256 |
CodeCacheFull 事件结构
字段 | 描述 | 举例 |
---|---|---|
开始时间 | 事件开始时间 | 1594215268138540326 epochns |
Event Thread | 编译线程,可能是 C1 CompilerThreadN 或者是 C2 CompilerThreadN,分别对应 C1,C2线程 | C1 CompilerThread0 |
Adaptors | 非方法堆里面元素数量 | 578 |
Code Heap | 事件指的是哪个代码堆,看 CacheCache目前处于什么阶段,如果是运行阶段,则可能是:CodeHeap 'non-profiled nmethods',CodeHeap 'profiled nmethods',CodeHeap 'non-nmethods',如果程序刚启动,JVM原生的占用non-nmethod就满了),可能就是 Unused,如果还没拆分出来,则是笼统的 CodeCache | Unused |
Commited Top | 代码缓存内存堆目前请求提交地址 | 140692249706496 |
Entries | 元素个数 | 91512 |
Full Count | 目前为止,CodeCacheFull次数 | 1 |
Methods | 编译方法数量 | 854 |
Reserved Top | 代码缓存内存堆地址 | 140692249706496 |
Start Address | 代码缓存内存堆开始地址 | 140692249706496 |
Unallocated | 未分配内存大小 | 2637824 B |
CodeCacheStatistics 事件结构
字段 | 描述 | 举例 |
---|---|---|
开始时间 | 事件开始时间 | 1594215268138540326 epochns |
Adaptors | 非方法堆里面元素数量 | 578 |
Code Heap | 事件指的是哪个代码堆,看 CacheCache目前处于什么阶段,如果是运行阶段,则可能是:CodeHeap 'non-profiled nmethods',CodeHeap 'profiled nmethods',CodeHeap 'non-nmethods',如果程序刚启动,JVM原生的占用non-nmethod就满了),可能就是 Unused,如果还没拆分出来,则是笼统的 CodeCache | Unused |
Entries | 元素个数 | 91512 |
Full Count | 目前为止,CodeCacheFull次数 | 1 |
Methods | 编译方法数量 | 854 |
Reserved Top | 代码缓存内存堆地址 | 140692249706496 |
Start Address | 代码缓存内存堆开始地址 | 140692249706496 |
Unallocated | 未分配内存大小 | 2637824 B |
CodeSweeperConfiguration 事件结构
字段 | 描述 | 举例 |
---|---|---|
开始时间 | 事件开始时间 | 1594215268138540326 epochns |
Code Cache Flushing Enabled | 是否启用了Code Cache Flushing,默认也都是启用的 | true |
Code Sweeper Enabled | 是否启用了CodeSweeper,默认都是启用的 | true |
CodeSweeperStatistics 我们不关心
SweepCodeCache 事件结构
字段 | 描述 | 举例 |
---|---|---|
开始时间 | 事件开始时间 | 1594215268138540326 epochns |
持续时间 | 事件持续时间 | 55607927 ticks |
结束时间 | 事件结束时间 | 2869587482704980480 epochticks |
Event Thread | 清理线程 | Sweeper thread |
Methods Flushed | 现在为止刷新的方法数量 | 5 |
Sweep Identifier | 全局唯一清理ID | 737 |
Methods Swept | 现在为止清理的方法数量 | 26700 |
Methods Zombified | 现在为止僵尸化的方法数量 | 2 |