Java即时编译器原理解析及实践

一、导读
常见的编译型语言如C++ , 通常会把代码直接编译成CPU所能理解的机器码来运行 。 而Java为了实现“一次编译 , 处处运行”的特性 , 把编译的过程分成两部分 , 首先它会先由javac编译成通用的中间形式——字节码 , 然后再由解释器逐条将字节码解释为机器码来执行 。 所以在性能上 , Java通常不如C++这类编译型语言 。
为了优化Java的性能, JVM在解释器之外引入了即时(Just In Time)编译器:当程序运行时 , 解释器首先发挥作用 , 代码可以直接执行 。 随着时间推移 , 即时编译器逐渐发挥作用 , 把越来越多的代码编译优化成本地代码 , 来获取更高的执行效率 。 解释器这时可以作为编译运行的降级手段 , 在一些不可靠的编译优化出现问题时 , 再切换回解释执行 , 保证程序可以正常运行 。
即时编译器极大地提高了Java程序的运行速度 , 而且跟静态编译相比 , 即时编译器可以选择性地编译热点代码 , 省去了很多编译时间 , 也节省很多的空间 。 目前 , 即时编译器已经非常成熟了 , 在性能层面甚至可以和编译型语言相比 。 不过在这个领域 , 大家依然在不断探索如何结合不同的编译方式 , 使用更加智能的手段来提升程序的运行速度 。
二、Java的执行过程
Java的执行过程整体可以分为两个部分 , 第一步由javac将源码编译成字节码 , 在这个过程中会进行词法分析、语法分析、语义分析 , 编译原理中这部分的编译称为前端编译 。 接下来无需编译直接逐条将字节码解释执行 , 在解释执行的过程中 , 虚拟机同时对程序运行的信息进行收集 , 在这些信息的基础上 , 编译器会逐渐发挥作用 , 它会进行后端编译——把字节码编译成机器码 , 但不是所有的代码都会被编译 , 只有被JVM认定为的热点代码 , 才可能被编译 。
怎么样才会被认为是热点代码呢?JVM中会设置一个阈值 , 当方法或者代码块的在一定时间内的调用次数超过这个阈值时就会被编译 , 存入codeCache中 。 当下次执行时 , 再遇到这段代码 , 就会从codeCache中读取机器码 , 直接执行 , 以此来提升程序运行的性能 。 整体的执行过程大致如下图所示:
Java即时编译器原理解析及实践文章插图
1. JVM中的编译器
JVM中集成了两种编译器 , Client Compiler和Server Compiler , 它们的作用也不同 。 Client Compiler注重启动速度和局部的优化 , Server Compiler则更加关注全局的优化 , 性能会更好 , 但由于会进行更多的全局分析 , 所以启动速度会变慢 。 两种编译器有着不同的应用场景 , 在虚拟机中同时发挥作用 。
Client Compiler
HotSpot VM带有一个Client Compiler C1编译器 。 这种编译器启动速度快 , 但是性能比较Server Compiler来说会差一些 。 C1会做三件事:

  • 局部简单可靠的优化 , 比如字节码上进行的一些基础优化 , 方法内联、常量传播等 , 放弃许多耗时较长的全局优化 。
  • 将字节码构造成高级中间表示(High-level Intermediate Representation , 以下称为HIR) , HIR与平台无关 , 通常采用图结构 , 更适合JVM对程序进行优化 。
  • 最后将HIR转换成低级中间表示(Low-level Intermediate Representation , 以下称为LIR) , 在LIR的基础上会进行寄存器分配、窥孔优化(局部的优化方式 , 编译器在一个基本块或者多个基本块中 , 针对已经生成的代码 , 结合CPU自己指令的特点 , 通过一些认为可能带来性能提升的转换规则或者通过整体的分析 , 进行指令转换 , 来提升代码性能)等操作 , 最终生成机器码 。
Server Compiler
Server Compiler主要关注一些编译耗时较长的全局优化 , 甚至会还会根据程序运行的信息进行一些不可靠的激进优化 。 这种编译器的启动时间长 , 适用于长时间运行的后台程序 , 它的性能通常比Client Compiler高30%以上 。 目前 , Hotspot虚拟机中使用的Server Compiler有两种:C2和Graal 。
C2 Compiler
在Hotspot VM中 , 默认的Server Compiler是C2编译器 。
C2编译器在进行编译优化时 , 会使用一种控制流与数据流结合的图数据结构 , 称为Ideal Graph 。 Ideal Graph表示当前程序的数据流向和指令间的依赖关系 , 依靠这种图结构 , 某些优化步骤(尤其是涉及浮动代码块的那些优化步骤)变得不那么复杂 。
Ideal Graph的构建是在解析字节码的时候 , 根据字节码中的指令向一个空的Graph中添加节点 , Graph中的节点通常对应一个指令块 , 每个指令块包含多条相关联的指令 , JVM会利用一些优化技术对这些指令进行优化 , 比如Global Value Numbering、常量折叠等 , 解析结束后 , 还会进行一些死代码剔除的操作 。 生成Ideal Graph后 , 会在这个基础上结合收集的程序运行信息来进行一些全局的优化 , 这个阶段如果JVM判断此时没有全局优化的必要 , 就会跳过这部分优化 。