自学内容网 自学内容网

通过JITWatch查看汇编代码,观测jdk17改动对代码的影响


前言

我们可以使用JITWatch来观测线上代码实际运行逻辑,来进行一些问题分析和学习参考。虽然线上运行的是机器指令,但是为了方便阅读和理解,JITWatch执行的都是汇编代码,是ARM指令集的汇编代码,不过咱们可以借助chat-gpt或者豆包等大模型帮忙解读


一、JITWatch是什么?

JITWatch是一个反编译查看工具,顾名思义,可以用来Watch JIT代码,帮助我们分析代码实际运行过程中的逻辑。

二、问题描述

1.公司线上问题

某个团队发现线上读TP999,总会时不时地出现一次抖动,通过公司的技术大盘观测,同一时间cpu.busy也会突然抖动一下。然后他们结合JFR,在线上服务再次复现问题时,观测到活跃线程居然是“C2 Compiler Thread”,咱们javaer看到这个,都会有点眼熟吧,毕竟各种八股文都背过。进一步观测发现,此时C2 Compiler Thread都是在进行分支预测(profile_predicate)失败导致的“逆优化,再编译”。

1.1 分支预测

其实这个问题,我也是学习到了,本来以为分支预测只会发生在操作系统调度指令流水器期间,如果预测失败,会清空指令流水线,重新加载另外一个分支。没想到C2编译器啊,你这个浓眉大眼的,居然在编译层面就做了一些分支预测的工作。

1.2 逆优化,再编译

咱们都知道C2的作用,就是为了做JIT,将字节码编译为机器码,然后运行。那逆优化,再编译,其实就是指编译的机器码出了问题,需要退化为字节码,再进行解释执行。
本来是操作系统可直接执行的机器指令,逆优化变为了Java的字节码指令,jvm的解释器需要借助CPU再次解释字节码指令为机器指令,然后CPU重新执行机器指令。也就是指令的生命周期那套。

2. 模拟问题(复现)

2.1 模拟环境

系统:macOS
Java版本:Java17
HotSpot17:直接下载就行
openjdk17:因为必须要修改源码,所以使用openjdk来做
JITWatch
:jitwatch-ui-1.4.7-shaded-mac.jar

下载地址:https://github.com/AdoptOpenJDK/jitwatch/releases

2.2 准备工作

  1. 下载openjdk17:https://github.com/openjdk/jdk17u
  2. 修改分支预测代码逻辑:src/hotspot/share/opto/parse2.cpp:1291,将branch_prediction改为以下代码
//-----------------------------branch_prediction-------------------------------
float Parse::branch_prediction(float& cnt,
                               BoolTest::mask btest,
                               int target_bci,
                               Node* test) {
    return PROB_FAIR;
}
  1. 编译openjdk17
    编译好的jdk的java_home:jdk17u/build/macosx-x86_64-server-slowdebug/images/jdk
  2. 下载JITWatch

2.3 复现步骤

  1. Java代码,咱们这段代码的某段逻辑一定不会走进去,也就是return 0x666
public class FirstTest {

    private static int test(int num) {
        if (num > 0) {
            return 0x123;
        }
        return 0x666;
    }

    public static void main(String[] args) {
        long res = 0;
        for (int j = 0; j < 100_000_000_0L; j++) {
            res += test(j);
        }
        System.out.println(res);
    }

}
  1. JITWatch,先用HotSpot Java17进行运行,查看C2编译器显示的汇编代码。通过观察汇编代码发现,C2编译器确实进行了分支预测优化,在生成的汇编代码里并没有0x666
# {method} {0x000000012ac00320} 'test' '(I)I' in 'FirstTest'
# parm0:    rsi       = int
#           [sp+0x20]  (sp of caller)
#上面一段代码可以看到将test方法入参放到了寄存器rsi当中
[Entry Point]
0x00000001188f5180: mov %eax,-0x14000(%rsp)  // 将 eax 寄存器的值存储到相对于栈指针 %rsp 的位置 -0x14000 处,这可能用于备份或特定堆栈布局要求。
0x00000001188f5187: push %rbp  // 将rbp寄存器数据压入到栈中,防止在方法运行过程中使用:rbp里面保存的是caller的帧指针,防止当前方法中也需要进行帧调用,所以把caller的帧指针保存起来
0x00000001188f5188: sub $0x10,%rsp  ;*synchronization entry // 为当前函数分配堆栈空间,减小栈指针 %rsp 的值。这分配了16字节的栈空间。
                                    ; - FirstTest::test@-1 (line 4)
// 以上是方法调用之前需要保存上下文,同时为当前方法之前分配栈空间
                                      
                                      
0x00000001188f518c: test %esi,%esi  // 测试寄存器 %esi 的值。这是一个比较步骤,将 esi 和它自己进行按位与运算,结果不会存储,但会设置标志寄存器(FLAGS)的 ZF 和 SF 等标志位。
// 根据代码解读,此时esi寄存器里放的应该是test方法的参数,通过执行test,然后判断零标志位ZF,来判断参数是否大于0
0x00000001188f518e: jle L0000  ;*ifle {reexecute=0 rethrow=0 return_oop=0}
                               ; - FirstTest::test@1 (line 4)
0x00000001188f5190: mov $0x123,%eax //将立即数 0x123(291)存入寄存器 eax 中。

  
// 以下是方法返回之前恢复方法调用Context,也就是caller的数据 
0x00000001188f5195: add $0x10,%rsp  //恢复栈指针rsp:把分配的栈空间回收
0x00000001188f5199: pop %rbp  // 恢复调用者的帧指针。
0x00000001188f519a: cmp 0x340(%r15),%rsp  ;   {poll_return} *** SAFEPOINT POLL *** // 比较 %rsp 和栈顶相对的某个值(存储在 %r15 + 0x340 中)。这是一个安全点检查,用于垃圾回收或其他线程同步需求。
0x00000001188f51a1: ja L0001  // 如果 %rsp 大于 %r15 + 0x340 的值,跳转到 L0001 标签。
0x00000001188f51a7: ret   //方法调用返回,可以看到从头到尾只是将0x123放到了寄存器eax中,然后return,c2的优化直接将0x666给忽略掉了。

// 异常链路调用:如果真发生了调用过程中else逻辑被C2给优化没了,此时就会进行下面处理步骤,也就是会发生逆优化,回到字节码,重新解释执行。这也就是CPU会突然尖刺的原因

             L0000: mov %esi,%ebp //将 %esi 的值复制到 %ebp。这里可能意味着我们需要将参数值保存到其他地方。
0x00000001188f51aa: mov $0xffffff45,%esi //将 0xffffff45 移动到 %esi,可能用于特殊标志或错误代码。
0x00000001188f51af: call 0x0000000110e7e000  ; ImmutableOopMap {} //调用运行时函数 UncommonTrapBlob,这是异常或特殊情况的处理函数。 
                                             ;*ifle {reexecute=1 rethrow=0 return_oop=0}
                                             ; - (reexecute) FirstTest::test@1 (line 4)
                                             ;   {runtime_call UncommonTrapBlob}

// 以下是正常返回链路调用:处理Safepoint
             L0001: movabs $0x1188f519a,%r10  ;   {internal_word}
0x00000001172f51be: mov %r10,0x358(%r15)
0x00000001172f51c5: jmp 0x000000010f87f100   ;{runtime_call SafepointBlob}//跳转到安全点处理函数 SafepointBlob,可能用于线程同步或垃圾收集。
  1. 我们再使用自己编译出来的openjdk17,重新运行下代码,看看对应的汇编代码。再看我们修改了之后重新编译的openjdk17,会把0x666也加载进寄存器
# {method} {0x0000000174400338} 'test' '(I)I' in 'ThirdTest'
# parm0:    rsi       = int
#           [sp+0x20]  (sp of caller)
[Entry Point]
0x00000001216ff620: mov %eax,-0x16000(%rsp) // 将 eax 寄存器的值存储到相对于栈指针 %rsp 的位置 -0x16000 处,这可能用于备份或特定堆栈布局要求。
0x00000001216ff627: push %rbp  // 将rbp寄存器数据压入到栈中,防止在方法运行过程中使用:rbp里面保存的是caller的帧指针,防止当前方法中也需要进行帧调用,所以把caller的帧指针保存起来
0x00000001216ff628: sub $0x10,%rsp  ;*synchronization entry // 为当前函数分配堆栈空间,减小栈指针 %rsp 的值。这分配了16字节的栈空间。
                                    ; - ThirdTest::test@-1 (line 4)
// 以上是方法调用之前需要保存上下文,同时为当前方法之前分配栈空间
                                      
                                      
0x00000001216ff62c: mov $0x666,%r11d //将立即数 0x666(十六进制的 1638)存入寄存器 r11d 中(寄存器 r11d 是 64 位寄存器 r11 的低 32 位部分)。
0x00000001216ff632: mov $0x123,%eax //将立即数 0x123(291)存入寄存器 eax 中。
0x00000001216ff637: test %esi,%esi // 测试寄存器 %esi 的值。这是一个比较步骤,将 esi 和它自己进行按位与运算,结果不会存储,但会设置标志寄存器(FLAGS)的 ZF 和 SF 等标志位。
// 根据代码解读,此时esi寄存器里放的应该是test方法的参数,通过执行test,然后判断零标志位ZF,来判断参数是否大于0
0x00000001216ff639: cmovle %r11d,%eax  //条件移动:如果前一步测试结果小于或等于零(ZF = 1 或 SF != OF),则将 r11d 的值移动到 eax 中。否则,eax 保持 0x123。
  
  
// 以下是方法返回之前恢复方法调用Context,也就是caller的数据  
0x00000001216ff63d: add $0x10,%rsp //恢复栈指针rsp:把分配的栈空间回收
0x00000001216ff641: pop %rbp // 恢复调用者的帧指针。
0x00000001216ff642: cmp 0x398(%r15),%rsp  ;   {poll_return} *** SAFEPOINT POLL *** // 比较 %rsp 和栈顶相对的某个值(存储在 %r15 + 0x398 中)。这是一个安全点检查,用于垃圾回收或其他线程同步需求。
0x00000001216ff649: ja L0000 // 如果 %rsp 大于 %r15 + 0x398 的值,跳转到 L0000 标签。
0x00000001216ff64f: ret //方法调用返回

// 以下指令是处理 Safepoint 的逻辑
             L0000: movabs $0x1216ff642,%r10  ;   {internal_word} // 将地址 0x1216ff642 移动到 r10 寄存器。
0x00000001216ff65a: mov %r10,0x3b0(%r15) // 将 r10 寄存器的值存储到地址 %r15 + 0x3b0。
0x00000001216ff661: jmp 0x000000012128d720  ;   {runtime_call SafepointBlob}// 无条件跳转到地址 0x000000012128d720。这个地址通常属于 JVM 的内部函数,用于处理安全点或其他运行时功能。

意外之喜

大家背八股文都知道,调用System.gc(),并不会立马触发gc,为啥呢,因为必须代码执行到某个安全点,才会去检查当前jvm里内存是否需要发生gc。
可以看我们上面反编译出来的两段汇编代码,都有跳转安全点检查等类似逻辑(runtime_call SafepointBlob)
当前还可以通过强制sleep,或者for循环改为long为计数点来让jvm进行安全点检查,但是这都不是本文要说明的东西

总结

  1. 没有想到C2编译器相对于C1编译器的高级优化(激进优化)里还有「分支预测」这一步
  2. JIT的编译优化也是有可能误判(错误优化)导致实际代码运行过程中需要进行逆优化再编译的

原文地址:https://blog.csdn.net/liangsheng_g/article/details/142722426

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!