编译器优化

2020-10-22   64 次阅读


字节码是如何运行的?

Java的两种运行模式

  • 解释执行:由解释器一行一行翻译执行
  • 编译执行:把字节码编译成机器码,直接执行机器码

解释 VS 编译

  • 解释执行
    • 优势在于没有编译的等待时间
    • 性能相对差一些(因为一行一行去翻译,性能可想而知不会很高)
  • 编译执行
    • 运行效率会高很多,一般认为比解释执行快一个数量级
    • 带来了额外的开销(比方说额外的内存开销,CPU开销等等)

查询运行模式

  • java -version
    image.png
    mixed mode(混合模式),就是部分代码解释执行,部分代码编译执行

  • -Xint(java -Xint -version):设置JVM的执行模式为解释执行模式
    image.png
    如果你想让你的Spring Boot项目以解释执行模式运行的话,只要执行:java -Xint -jar xxx.jar 即可

  • -Xcomp(java -Xcomp -version):JVM优先以编译模式运行,不能编译的,以解释模式运行

  • -Xmixed(java -Xmixed -version):混合模式运行(默认情况下就是混合模式运行)

一般情况下

  • (我们的代码)一开始一般由解释器解释执行
  • 当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会认为这些代码是“热点代码”。为了提高热点代码的执行效率,会用即时编译器(也就是JIT)把这些热点代码编译成与本地平台相关的机器码,并进行各层次的优化,详情见下方分层编译内容
    • 本地平台多种层次的含义:比方说操作系统的不同(Linux/Windows),又比如CPU架构的不同(X86CPU架构/ARMCPU架构)

Hotspot的两个即时编译器(C1/C2)

  • C1编译器(适合用来执行客户端程序):

    • 是一个简单快速的编译器
    • 主要关注局部性的优化
    • 适用于执行时间较短或对启动性能有要求的程序。例如,GUI应用对界面启动速度就有一定要求,又例如idea(一般来说我们希望这种程序启动的时候能够快一些,这个时候就比较适合用C1编译器)
    • 也被称为是Client Compiler
  • C2编译器(适合用来执行服务端程序):

    • 是为长期运行的服务器端应用程序做性能调优的编译器
    • 适用于执行时间较长或对峰值性能有要求的程序
    • 也被称为是Server Compiler(比方说我们的Spring Boot应用程序,它就是一个长期运行的服务端应用程序,就比较适合用C2去执行)

分层编译(各层次的优化;从JDK7开始正式引入了分层编译的概念,可以细分为五种编译级别)

  • 0:解释执行
  • 1:简单C1编译:会用C1编译器进行一些简单的优化,不开启Profiling(JVM的性能监控)
  • 2:受限的C1编译:仅执行带方法调用次数以及循环回边执行次数Profiling的C1编译
  • 3:完全C1编译:会执行带有所有Profiling的C1代码
  • 4(最高级别):C2编译:使用C2编译器进行优化,该级别会启用一些编译耗时较长的优化,一些情况下会根据性能监控信息进行一些非常激进的性能优化
  • 级别越高,应用的启动也会越慢,优化的开销也会越高,峰值性能也会越高

分层编译-JVM参数配置(默认情况下,JDK8是开启分层编译)

# 只想开启C2
-XX:-TieredCompilation(禁用中间编译层(123层))
# 只想开启C1,如果TieredStopAtLevel设置成3,则只会使用0123这个级别
-XX:-TieredCompilation -XX:TieredStopAtLevel=1

如何找到热点代码?思路是?

  • 基于采样的热点探测
    • 大致的思路是,周期检查各个线程的栈顶,如果发现某一些方法老是出现在栈顶,说明这个方法是热点方法;每一个线程都会有自己独立的栈,进入一个方法的时候,会往栈里面压入一个元素,并且放到栈的顶部,如果经过周期性的检查发现某一些方法老是出现在栈顶,说明很多的线程老是在执行这个方法,那么说明这个方法是热点方法
  • 基于计数器的热点探测
    • 大致思路是为每一个方法或者是代码块建立计数器,然后统计执行的次数,如果超过一定阈值就认为它是热点方法;

HotSpot虚拟机是基于计数器的热点探测,它为每个方法准备了两类计数器:

  • 方法调用计数器(Invocation Counter)
    • 用于统计方法被调用的次数,在不开启分层编译的情况下,在C1编译器下的默认阈值是1500次,在C2模式下是10 000次,也可用-XX:CompileThreshold=X指定阈值,达到这个阈值就认为是热点方法
  • 回边计数器(Back Edge Counter)
    • 用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。在不开启分层编译的情况下,C1编译器下的默认阈值是13995,C2默认为10700,可使用-XX:OnStackReplacePercentage=X指定阈值
    • 建立回边计数器的主要目的是为了触发OSR(OnStackReplacement)编译,所谓的OSR编译,是一种在运行时替换正在运行的函数的栈帧的技术;参考文档https://www.zhihu.com/question/45910849/answer/100636125
  • 当开启分层编译时,JVM会根据当前待编译的方法数以及编译线程数来动态调整阈值,-XX:CompileThreshold、-XX:OnStackReplacePercentage都会失效

方法调用计数器流程
image.png
一个方法在被调用的时候,
会先检查这个方法是否存在已编译版本
如果已经有编译过的版本就直接执行编译后的代码
如果还不存在编译过的版本那么就会把这个方法的方法调用计数器值+1
然后去判断方法调用计数器回边计数器的和是否超过阈值,
如果超过阈值的话就会向编译器提交编译请求(这里的编译器可能是C1,也可能是C2),
提交完请求后,执行引擎并不会同步等待编译请求完成,而是继续以解释方法运行代码
当编译完成后,下一次调用这个方法就会执行编译后的代码

方法调用计数器

  • 如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰周期。进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。另外,可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒

回边计数器流程
image.png
当解释器遇到一条回边指令的时候,会先查找一下要先执行的代码片段是否存在已编译版本
如果有的话就直接执行编译后的代码
如果没有那么回边机器数值+1
然后会判断方法调用计数器和回边计数器的和是否超过阈值
如果超过阈值就会向编译器提交OSR编译请求
同时会把回边计数器的值降低一些,
然后继续以解释方式运行代码
当编译器编译结束之后,后面的请求就可以编译执行了;

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

那一年,我也变成了光!!