深入理解Java虚拟机::后端编译与优化

后端编译与优化

  • Java 世界里,虽然提前编译(Ahead Of Time,AOT)早已有所应用,但相对而言,即时编译(Just In Time,JIT)才是占绝对主流的编译形式

  • 解释器与编译器

    • 主流的商用 Java 虚拟机,内部都同时包含解释器与编译器
    • 解释器与编译器两者各有优势
      • 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行
      • 当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率
    • 解释器还可以作为编译器激进优化时后备的“逃生门”,当激进优化的假设不成立,如加载了新类以后,类型继承结构出现变化、出现“罕见陷阱”(Uncommon Trap)时可以通过逆优化(Deoptimization)退回到解释状态继续执行
    • 内置即时编译器
      • 客户端编译器
      • 服务端编辑器
      • 实验性 Graal 编译器
    • 解释器与编译器搭配使用的方式在虚拟机中被称为“混合模式”(Mixed Mode)
    • 分层编译
      • 第 0 层。程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
      • 第 1 层。使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。
      • 第 2 层。仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
      • 第 3 层。仍然使用客户端编译器执行,开启全部性能监控,除了第 2 层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
      • 第 4 层。使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。
    • 热点代码
      • 被多次调用的方法。
      • 被多次执行的循环体。
    • 编译器依然必须以整个方法作为编译对象
    • 热点探测
      • 周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”
      • 基于计数器的热点探测(Counter Based Hot Spot Code Detection)。采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。
    • 回边计数器
      • 统计一个方法中循环体代码执行的次数
      • 当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,它将会优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个栈上替换编译请求,并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果
    • 编译过程
      • 在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表
      • 在第二个阶段,一个平台相关的后端从 HIR 中产生低级中间代码表示
      • 最后的阶段是在平台相关的后端使用线性扫描算法在 LIR 上分配寄存器,并在 LIR 上做窥孔优化,然后产生机器代码
    • 经典优化手段
      • 无用代码消除(Dead Code Elimination)
      • 循环展开 (Loop Unrolling)
      • 循环表达式外提(Loop Expression Hoisting)
      • 消除公共子表达式(Common Subexpression Elimination)
      • 常量传播(Constant Propagation)
      • 基本块重排序(Basic Block Reordering)
      • 范围检查消除(Range Check Elimination)
      • 空值检查消除(Null Check Elimination)
      • 如守护内联(Guarded Inlining)
      • 分支频率预测 (Branch Frequency Prediction)
  • 提前编译器

    • 提前编译的优劣得失
      • 现在提前编译产品和对其的研究有着两条明显的分支,一条分支是做与传统 C、C++编译器类似的,在程序运行之前把程序代码编译成机器码的静态翻译工作;另外一条分支是把原本即时编译器在运行时要做的编译工作提前做好并保存下来,下次运行到这些代码(譬如公共库代码在被同一台机器其他 Java 进程使用)时直接把它加载进来使用。
    • 天然优势
      • 首先,是性能分析制导优化
      • 其次,是激进预测性优化
      • 最后,是链接时优化
  • 编译器优化技术

    • 方法内联
      • 消除方法调用的成本
      • 为其他优化手段建立良好的基础
    • 逃逸分析
      • 分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
      • 栈上分配
        • 在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不能支持线程逃逸。
      • 标量替换:若一个数据已经无法再分解成更小的数据来表示了,Java 虚拟机中的原始数据类型都不能再进一步分解了,那么这些数据就可以被称为标量 。
      • 同步消除:线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以安全地消除掉。
    • 公共子表达式消除
      • 如果一个表达式 E 之前已经被计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替 E。如果这种优化仅限于程序基本块内,便可称为局部公共子表达式消除,如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除。
    • 数组边界检查消除
      • 每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这必定是一种性能负担。
      • 数组访问发生在循环之中,并且使用循环变量来进行数组的访问。如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间[0,foo.length)之内,那么在循环中就可以把整个数组的上下界检查消除掉,这可以节省很多次的条件判断操作。