JVM学习之运行期优化知识点

JAVA简介

  • 基本语言特性(面向对象(封装,继承,多态),泛型,Lambda,反射)
  • 平台无关性(JVM运行.class文件,符合平台的字节码)
  • 核心类库(集合,并发,网络,IO/NIO,安全类库等)
  • JDK (Java开发工具包,JRE,JVM,API类库)
  • JRE(Java运行环境,JVM,Javase核心类库)
  • JVM(垃圾收集器,运行时,动态编译,辅助功能JFR等)
  • JVM作为一个平台,不仅仅Java语言可以运行在JVM上,本质上合规的字节码都可以运行,比如:Clojure、Scala、Groovy、JRuby、Jython等大量JVM语言

JAVA如何运行的?

  • Java源码经过Javac编译成.class文件
  • .class文件经过JVM解析或编译运行
    1. 解释器解析:.class文件经过JVM内嵌的解析器解析执行;
    2. 即时编译器JIT:把经常运行的 字节码 作为“热点代码”编译成与本地平台相关的机器码,并进行各层次的优化;
    3. 预编译器AOT:JDK9引入这个特性,并且增加了新的jaotc工具,
      将代码编译成机器码执行。

JVM运行期优化

jvm 优化知识点图

为何HotSpot虚拟机要使用解释器与编译器并存架构?

解释器优点:

  • 当程序需要快速启动和执行的时候,省去了编译的时间,立即执行;
  • 作为编译器激进优化的“逃生门”,当激进优化的假设不成立的时候(比如:加载新类后类型继承结构出现变化、出现”罕见陷阱(Uncommon Trap)” 等时候,通过逆向优化(Deoptimization)退回到解释状态继续执行,(部分没有解释器的虚拟机中也会采用不进行激进优化的C1编译器担任“逃生门”的角色);
  • 当程序运行环境资源限制较大(如嵌入式系统),可以使用解释执行节约内存,反之可以使用编译执行来提升效率。

编译器优点:

  • 在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获得更高的执行效率;
  • 基于运行分析,进行热点代码编译的设计,是因为绝大部分程序都表现为“小部分的热点代码耗费了大多数的资源”。

为何HotSpot虚拟机要使用两个不同的即时编译器?

  • 由于即时编译器编译字节码需要占用程序运行时间,要编译出优化程度更高的代码所花费的时间可能更长;而且想要编译出优化程度跟高的代码,解释器可能还要替代编译器收集性能监控信息,这对解释器执行的速度也有影响;为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机还会逐渐启用分层编译(Tiered Compilation)的策略。

  • 两个编译器分别称为:C1(Client Compile)和 C2(Server Compile);

  • 分成编译(Tiered Compilation):
    1. 第 0 层:程序解释执行,解释器不开启性能监控功能(Profiling) 可触发第 1 层编译;
    2. 第 1 层:也称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑;
    3. 第 2 层或及以上:也成为C2编译,也将字节码编译为本地代码,但是会启用一些编译耗时的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
  • 实施分成编译后,C1 和 C2 将会同时工作,许多字节码都会被多次编译,用 C1 获取更高的编译速度, 用 C2 来获取更好的编译质量,在解释执行的时候也无须再承担收集性能监控信息的任务。

程序何时使用解释器执行?何时使用编译器执行?哪些代码会编译为本地代码

  • 即时编译的“热点代码”有两类,即:(方法级粒度,以整个方法作为编译对象)

    1. 被多次调用的方法;
    2. 被多次执行的循环体(方法内部存在循环次数较多的循环体)。
  • 前者很好理解,一个方法被调用得多了,方法体内代码执行的次数自然就多,它成为“热点代码”是理所当然的。而后者则是为了解决一个方法只被调用过一次或少量的几次,但是方法体内部存在循环次数较多的循环体的问题,这样循环体的代码也被重复执行多次,因此这些代码也应该认为是“热点代码”;

  • 对于第一种情况,由于是由方法调用触发的编译,因此编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机中标准的JIT编译方式。而对于后一种情况,尽管编译动作是由循环体所触发的,但编译器依然会以整个方法(而不是单独的循环体)作为编译对象。这种编译方式因为编译发生在方法执行过程之中,因此形象地称之为栈上替换(On Stack Replacement,简称为OSR编译,即方法栈帧还在栈上,方法就被替换了)。

  • 判断一段代码是不是 “ 热点代码 ”,是不是需要触发即时编译,这样的行为称为 热点探测,目前主要的热点探测判断方式有两种,分别如下:

    1. 基于采样的热点探测(Sample Based Hot Spot Detection): 采用这种方法的虚拟机会周期性地检查各个线程的栈顶, 如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点代码”;

      • 优点:实现简单、高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可);
      • 缺点:很难精确地确认一个方法的热度,容易因为受线程阻塞或别的外界因素的影响而扰乱热点探测。
    2. 基于计数器的热点探测(Counter Based Hot Spot Detection): 采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点代码”;

      • 优点:结果更加精确和严谨;
      • 缺点:统计方法实现起来麻烦些,需要建立并维护计数器,而且不能直接获取方法的调用关系。
  • HotSpot虚拟机使用的是第2种-基于计数器的热点探测方法,因此它为每个方法准备了两类计数器:[方法调用计数器(Invocation Counter) ] 和 [回边计数器(Back Edge Counter)]:

    1. 方法调用计数器(Invocation Counter):
      • 阈值(默认是:C1-1500, C2-10000),可以通过虚拟机参数[-XX:CpmpileThreshold] 来人为设置;

      • 当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求;

      • 如果不做任何设置,执行引擎并不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,这个地方是异步形式,直到提交请求被编译器编译完成,当编译工作完成之后,这个方法调用入口地址就会被系统自动改写成新的,下一次调用该方法时,就会使用已编译的版本;

      • 如果不做任何设置,方法计数器统计的并不是方法调用的绝对次数,而是一个相对次数,即一段时间内方法被调用的次数;当超过一定时间限度,如果方法的调用次数仍然不足让它提交给即时编译器编译,那这个方法的调用计数器就会被减半,这个过程称为[方法调用计数器的衰减(Counter Decay)],进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以是用虚拟机参数[-XX: -UserCounterDecay]来关闭热度衰减,同时也可以使用虚拟机参数[***-XX: CounterHalfLifeTime]设置半衰周期的时间,单位秒。

      • JIT编译交互过程如下:


        Invocation Counter JIT Interaction flow
  1. 回边计数器(Back Edge Counter):(目的就是为了触发OSR编译)
    • 虚拟机运行在Clinent(C1)模式下,OnStackReplacePercentage默认值为933,都是取默认值的情况下,Client模式下回边计数器的阈值是13995,回边计数器阈值计算公式为:

       方法调用计数器阈值(CompileThreshold)X OSR比率(OnStackReplacePercentage)/ 100
      
    • 虚拟机运行在Server(C2)模式下, OnStackReplacePercentage默认值为140,InterpreterProfilePercentage默认值为33,都是取默认值的情况下,Server模式下回边计数器的阈值是10700,回边计数器阈值计算公式为:

       方法调用计数器阈值(CompileThreshold)X(OSR比率(OnStackReplacePercentage)- 解释器监控比率(InterpreterProfilePercentage))/ 100
      
    • 当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有,它将会优先执行已编译的代码,否则就把回边计数器的值加1,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个OSR编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。

    • JIT编译交互过程如下:

Back Edge Counter JIT Interaction flow
  1. 总结
    • 相比于方法计数器,回边计数器没有计数热度衰减过程,因此这个计数器统计的就是该方法循环体执行的绝对次数;
    • 以上两点仅仅描述了Client VM(C1)的即时编译方式。

如何编译为本地代码

  • 在默认设置下,无论是方法调用产生的即时编译请求,还是OSR编译请求,虚拟机在代码编译器还未完成之前,都仍然将按照解释方式继续执行,而编译动作则在后台的编译线程中进行。用户可以通过参数-XX:-BackgroundCompilation来禁止后台编译,在禁止后台编译后,一旦达到JIT的编译条件,执行线程向虚拟机提交编译请求后将会一直等待,直到编译过程完成后再开始执行编译器输出的本地代码。
  1. 对于Client Compiler来说,它是一个简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段:

    • 在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representaion,HIR)。HIR使用静态单分配(Static Single Assignment,SSA)的形式来代表代码值,这可以使得一些在HIR的构造过程之中和之后进行的优化动作更容易实现。在此之前编译器会在字节码上完成一部分基础优化,如方法内联、常量传播等优化将会在字节码被构造成HIR之前完成。

    • 在第二个阶段,一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level Intermediate Representation,LIR),而在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。

    • 最后阶段是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在LIR上分配寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码。

  1. Server Compiler则是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器,几乎能达到GNU C++编译器使用-O2参数时的优化强度,它会执行所有经典的优化动作,如无用代码消除(Dead Code Elimination)、循环展开(Loop Unrolling)、循环表达式外提(Loop Expression Hoisting)、消除公共子表达式(Common Subexpression Elimination)、常量传播(Constant Propagation)、基本块重排序(Basic Block Reordering)等,还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除(Range Check Elimination)、空值检查消除(Null Check Elimination,不过并非所有的空值检查消除都是依赖编译器优化的,有一些是在代码运行过程中自动优化了)等。另外,还可能根据解释器或Client Compiler提供的性能监控信息,进行一些不稳定的激进优化,如守护内联(Guarded Inlining)、分支频率预测(Branch Frequency Prediction)等;

  2. Server Compiler的寄存器分配器是一个全局图着色分配器,它可以充分利用某些处理器架构(如RISC)上的大寄存器集合。以即时编译的标准来看,Server Compiler无疑是比较缓慢的,但它的编译速度依然远远超过传统的静态优化编译器,而且它相对于Client Compiler编译输出的代码质量有所提高,可以减少本地代码的执行时间,从而抵消了额外的编译时间开销,所以也有很多非服务端的应用选择使用Server模式的虚拟机运行。

Client Compile的执行过程图

文中如有错误点请指出,互相学习,谢谢!

参考资料:

推荐阅读更多精彩内容