一、概述
热点代码:某个方法或代码块运行的特别频繁时,Hotspot 虚拟机就会将这些代码认定为热点代码。
然后会用 JIT 编译器将这些代码编译成机器码,并进行各种层次的优化。
二、Hotspot 内的即时编译器
1、解释器与编译器并存
解释器:可以省去编译时间,立即执行
编译器:执行效率更高
逆优化可以使代码退回到解释状态继续执行。
Client Compiler
:C1
Server Compiler
:C2
默认采用解释器与其中一个编译器直接配合的方式工作。
选用 C1 还是 C2 取决于虚拟机的运行模式,可以通过 -client 或者-server 参数来指定
Hotspot 虚拟机用分层编译策略:
- 第 0 层,程序解释执行,解释器不开启性能监控功能,可触发第 1 层编译
- 第 1 层,也称为 C1 编译,将字节码编译为本地代码,进行简单可靠的优化,有必要的话将加入性能监控的逻辑
- 第 2 层,称为 C2 编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息来采取一些不可靠的激进优化。
分层编译的情况下,C1 和 C2 同时工作,许多代码会被多次编译,用 C1 获取更高的编译速度,用 C2 获取更好的编译质量。
2、编译对象与触发条件
热点代码:
- 被多次调用的方法
- 被多次执行的循环体
这两种都以整个方法作为编译对象,但是后者的编译发生在方法执行过程之中,所以被称为栈上替换 OSR 编译。
Hotspot 采用的是基于计数器的热点探测方法,为每个方法准备了 方法计数器和回边计数器。
这两个计数器都有一个阈值,超过阈值后会触发 JIT 编译。
1)方法计数器触发 JIT 过程
方法计数器不是精确的绝对值,他指的是一段时间内方法被调用的次数。
当超过一段时间限度,计数器会被减半,称为热度衰减,在 GC 时发生。
2)回边计数器触发 JIT 过程
在字节码中遇到控制流向后跳转的指令称为回边。
3、编译过程
默认情况下,JIT 编译和 OSR 编译都是在后台线程进行的,在他们完成之前,虚拟机按照解释方式进行。
后台编译主要有三个阶段:
- 将字节码进行一部分优化,并构造成一种高级中间代码表示 HIR
- 将 HIR 进行一部分优化,并产生低级中间代码 LIR
- 在后端使用线性扫描算法在 LIR 上分配寄存器等,产生本地机器代码
Server Compiler 是面向服务端的,并为服务端性能配置调整优化过的编译器。
- 会执行所有的经典优化动作
- 寄存器分配器是一个全局图着色分配器
- 编译速度超过传统的静态优化编译器,输出代码质量高于 client ,可以减少本地代码执行时间
三、编译优化技术
JIT 编译器有着所有的代码优化措施,因此 JIT 产生的本地代码会比 javac 产生的字节码更优秀。
1、公共子表达式消除
就是一个计算过的表达式,如果没有改变,就可以复用他的值,不需要重新计算
2、数组边界检查消除
每次数组元素的读写都带有一次隐含的条件判定操作,当频繁操作数组时,是一种性能负担。
编译器可以通过数据流分析就可以判定循环变量的取值范围永远在[0, foo.length)之内,那么在整个循环中就可以把数组的上下边界检查消除,可以节省很多次的条件判断。
3、方法内联
一是为了消除方法调用的成本,二是为了其他优化手段打基础。
但是,只有静态方法、私有方法、实例构造器、父类方法,这 4 类是非虚方法,是在编译期间就可以解析确定的。其他方法调用都可能存在多个选择。
所以虚拟机团队用了 CHA 技术,类型继承关系分析,用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类等信息。
当遇到虚方法时,编译器会向 CHA 查询该方法的版本,如果只有一个版本,直接进行内联。
如果有多个版本,那么可以使用内联缓存,当调用方法接受者发生变化时,再取消内联,从虚方法表中进行方法分派。
4、逃逸分析
当一个方法中的对象传递到其他方法中时,称为方法逃逸,当被其他线程访问到时,称为线程逃逸。
如果可以证明一个对象不会逃逸,就可以针对他做一些优化:
- 栈上分配:对不会逃逸的对象使用栈上分配,可以让他随着方法结束而自动销毁,减少了 GC 消耗
- 同步消除:对不会逃逸的对象,可以省略同步的措施
- 标量替换:可以将不会逃逸的对象拆分成一个个标量,然后分配在栈上
但是目前判断一个对象是否会逃逸,成本很高,所以虚拟机中都是默认关闭的。
四、Java 与 C/C++编译器对比
Java 编译器的劣势:
- Java 的即时编译器会占用用户的运行时间,所以优化手段也受制于编译成本。而 C++的静态优编译器对时间不是主要关注
- Java 是动态的类型安全语言,所以虚拟机要频繁的进行动态检查,这样会消耗不少时间
- Java 调用虚方法的频率远远高于 C 语言,在对方法接受者多态选择的频率也更高,因此优化难度高于 C 的静态编译器
- Java 是动态扩展的,因此编译器很难进行全局的优化,有时候只能采用激进方式
- Java 对象分配都在堆上,而 C有多种内存分配方式,加上 C 用代码进行内存回收,因此运行效率要高于 GC,内存回收压力也更小