Chapter 11.2 - 即时编译器
Created by : Mr Dk.
2020 / 02 / 02 21:56 🧨🧧
Ningbo, Zhejiang, China
11.1 概述
编译器将 Class 文件转换成与本地基础设施相关的二进制代码被视为编译过程的后端。后端编译器的好坏、代码优化质量的高低是商业 JVM 的核心,是最能体现技术水平与价值的功能。
11.2 即时编译器 (Just In Time, JIT)
Java 程序最初都是通过解释器 (Interpreter) 进行解释执行。当 JVM 发现某个函数或代码块运行特别频繁,就会把这些代码认定为 热点代码 (Hot Spot Code)。JVM 会将这些代码编译成本地机器码,并用各种手段对代码进行优化,运行时完成这个任务的后端编译器被称为 即时编译器。
11.2.1 解释器与编译器
当程序需要迅速启动和执行时,解释器可以首先发挥作用:
- 省去编译的时间,立即运行
- 节约内存
随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码:
- 减少解释器的中间损耗,获得更高的执行效率
- 解释器可以作为编译器激进优化后假设不成立的后备逃生门,让编译器根据概率选择一些大多数时候都能提升运行速度的优化手段
HotSpot 虚拟机内置了三个即时编译器:
- 客户端编译器 (Client Compiler) (C1)
- 服务端编译器 (Server Compiler) (C2)
- Graal (JDK 10)
解释器与编译器搭配使用的方式在 JVM 中被称为 混合模式 (Mixed Mode):
- 解释模式 (Interpreted Mode) - 编译器完全不介入工作
- 编译模式 (Compiled Mode) - 优先采用编译方式执行程序,但解释器仍然要在编译无法进行时介入
为了在 程序启动响应速度 和 运行效率 之间达到最佳平衡,HotSpot JVM 在编译子系统中加入了 分层编译 的功能:
- 程序纯解释执行,解释器不开启性能监控
- 客户端编译器 + 简单可靠的优化 + 不开启性能监控
- 客户端编译器 + 仅开启有限的性能监控
- 客户端编译器 + 开启全部性能监控
- 服务端编译器 + 根据性能监控信息进行不可靠的激进优化
实施分层编译后,解释器、客户端、服务端同时工作,热点代码可能会被多次编译:
- 用客户端编译器获取更高的编译速度
- 用服务端编译器获取更好的编译质量
11.2.2 编译对象与触发条件
即时编译器的编译目标:
- 被多次调用的函数
- 被多次执行的循环体
编译的目标对象都是整个函数体,不会是单独的循环体。编译器以整个函数为编译对象,只是执行入口不同 - 编译时会传入执行入口的字节码序号。由于编译发生在函数执行过程中,因此被称为 栈上替换 (On Stack Replacement, OSR):函数的栈帧还在栈上,函数就已经被替换了。
要知道某段代码是否触发即时编译的行为称为 热点探测 (Hot Spot Code Detection)
- 基于采用的热点探测 (Sample Based)
- JVM 周期性检查各线程的调用堆栈
- 如果发现某个函数经常出现在栈顶,该函数就是热点函数
- 不精确,受线程阻塞等因素的影响
- 基于计数器的热点探测 (Counter Based)
- 为每个函数甚至代码块建立计数器
- 计数器超过阈值就会被认为是热点函数
- 实现麻烦,但统计结果更加严谨
HotSpot JVM 为每个函数准备了两个计数器:
- 函数调用计数器 (Invocation Counter)
- 回边计数器 (Back Edge Counter)
两个计数器都有明确且可配置的阈值,阈值一旦溢出,就会触发即时编译。当函数被调用时,首先检查函数是否存在即时编译版本:
- 如果存在,则优先使用编译后的本地代码
- 如果不存在,则将函数的调用计数器 +1
判断两个计数器之和是否超过调用计数器的阈值。如果超出,则提交编译请求,默认执行引擎不会同步等待编译请求完成,而是继续使用解释器执行字节码,直到提交的请求被即时编译器编译完成。此时,该函数的调用入口地址会被改写为新值。当超过一定的时间后,函数的调用次数仍不足以触发即时编译,函数的调用计数器会被减半 - 称为 热度衰减 (Counter Decay),这段时间被称为 半衰周期 (Counter Half Life Time)。该动作在 GC 时进行,如果没有热度衰减,只要系统运行时间足够长,绝大部分函数都会被即时编译为本地代码。
在字节码中,遇到控制流向后跳转的指令称为 回边 (Back Edge)。当解释器遇到回边指令时,首先查找代码片段是否有编译版本:
- 如果有,则优先执行已编译的代码
- 否则把回边计数器的值 +1
之后判断两个计数器之和是否超出回边计数器阈值。如果超过阈值,则提交栈上替换编译请求,并把回边计数器的值稍微降低一些,以等待编译结果。回边计数器没有热度衰减,当其溢出时,会把调用计数器也设置为溢出,以便下次进入该函数时进行触发编译。
11.2.3 编译过程
JVM 在编译期未完成编译之前,都使用解释方式执行代码,编译动作在后台的编译线程中进行。如果后台编译被禁止,执行线程在提交编译请求后将一直阻塞等待直到编译完成。
客户端编译器:
- 相对简单快速的三段式编译器
- 重点关注局部性优化
- 放弃了耗时较长的全局优化
- 一个平台独立的前端将字节码构造为 HIR,同时进行优化
- 一个平台相关的后端从 HIR 中产生 LIR,同时进行优化
- 在平台相关的后端使用线性扫描算法在 LIR 上分配寄存器、优化、生成机器代码
服务端编译器:
- 专门面向服务端的典型应用场景
- 会执行大部分经典的优化动作,几乎能达到 GNU C++
-O2
参数的优化强度 - 实施一些 Java 语言特性相关的优化技术
- 根据解释器或客户端编译器提供的性能监控信息,进行一些不稳定的预测性激进优化
以即时编译的标准来看,服务端编译器的速度缓慢,但编译速度远远超过传统的静态优化编译器,相对于客户端编译器输出的代码质量有很大提高,可以大幅减少本地代码的执行时间。