BiBi - JVM -12- 运行期优化
From:深入理解Java虚拟机
- 目录
BiBi - JVM -0- 开篇
BiBi - JVM -1- Java内存区域
BiBi - JVM -2- 对象
BiBi - JVM -3- 垃圾收集算法
BiBi - JVM -4- HotSpot JVM
BiBi - JVM -5- 垃圾回收器
BiBi - JVM -6- 回收策略
BiBi - JVM -7- Java类文件结构
BiBi - JVM -8- 类加载机制
BiBi - JVM -9- 类加载器
BiBi - JVM -10- 虚拟机字节码
BiBi - JVM -11- 编译期优化
BiBi - JVM -12- 运行期优化
BiBi - JVM -13- 并发
JIT【Just In Time】出现的原因
Java程序最初通过解释器进行解释执行,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码定为【热点代码】。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器就成为即时编译器。
JIT并不是虚拟机的必需部分,但即时编译器【性能的好坏、代码的优化程度】却是衡量一款虚拟机的关键指标之一。
Java虚拟机规范中没有具体约束和限制即时编译器应该如何实现,所以JIT是跟具体虚拟机的实现有关的。本文以HotSpot虚拟机中的JIT为例说明。
解释器和编译器的对比
1)当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,可以获取更高的执行效率。
2)当程序运行环境内存资源限制较大,如:嵌入式系统,可以使用解释执行节约内存;反之使用编译器执行来提升效率。
3)解释器可以作为编译器激进优化时的一个【逃生门】,让编译器根据概率选择一些大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立时,可以通过逆优化退回到解释状态继续执行。【即编译器失败后,采用解释器继续执行】
HotSpot虚拟机中内置了两个即时编译器,C1【Client Compiler】和 C2【Server Compiler】。HotSpot会根据自身版本与宿主机器的硬件性能自动选择一个即时编译器,并与解释器配合工作。
为了在程序响应速度和运行效率之间达到最佳平衡,HotSpot会逐渐启用【分层编译】策略,其中包括:
第0层:程序解释执行,解释器不开启性能监控功能,可触发第1层编译。
第1层:也称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑。【只关注局部优化,放弃了耗时较长的全局优化】
第2层:也称为C2编译,将字节码编译为本地代码,开启一些编译较长的优化,甚至会根据性能监测信息进行一些不可靠的激进优化。
分层编译后,C1【Client Compiler】和C2【Server Compiler】将同时工作,许多代码可能被多次编译,用C1获取更高的编译速度,用C2获取更好的编译质量。
JIT的编译对象
即时编译的【热点代码】有两类:
1)被多次调用的方法
2)被多次执行的循环体
这两种情况都是以整个方法作为编译对象。对于情况2)而言,JIT编译发生在方法执行过程之中,方法栈桢还在栈上,该方法就被替换为JIT编译的产物,因此形象的称为【栈上替换,On Stack Replacement,OSR编译】。
JIT触发的条件
【多次执行】中的多次是如何计算的,即如何判断一段代码是不是热点代码,需不需要即时编译,这种行为称为【热点探测】。热点探测的两种方式:
1)基于采样的热点探测
虚拟机周期性的检查各个线程的栈顶,当发现某个方法经常出现在栈顶,那这个方法就是热点方法。该方法能够容易的获取方法调用关系【将调用栈展开即可】。
2)基于计数器的热点探测【HostSpot采用】
虚拟机为每个方法建立计数器,统计方法的执行次数,如果超过阈值则为热点方法。
HotSpot使用第二种基于计数器的热点探测,每个方法中有两类计数器:方法调用计数器和回边计数器。阈值默认:在Client模式下为1500次,在Server模式下为10000次。
方法调用计数器统计的不是方法被调用的绝对值,因为当超过一定的时间限制,如果方法的调用次数还是不能达到阈值,那这个方法计数器的值就会减半,这个过程称为【方法调用计数器热度的衰减】。
热度衰减的动作是在垃圾收集时顺便进行的。可以通过参数关闭热度衰减,这样方法调用计数器统计的就是绝对值了。
回边计数器:用于统计一个方法中循环体代码执行的次数。
在字节码中遇到控制流向后跳转的指令称为【回边】。
统计的是绝对值,它没有热度衰减过程。
还有其它热点探测方法,如:基于踪迹的热点探测,Android中的Dalvik虚拟机就是使用该种方法。
触发JIT的过程
方法调用计数器触发即时编译注意:当两个计数器之和超过阈值时,向编译器提交JIT请求,这个时候不会等待JIT的结果,而是继续按照解释器的方式执行字节码,直到JIT完成。当JIT完成之后,这个方法的调用入口地址就被系统自动改写成新的。即虚拟机在代码编译器还没有完成之前,都将继续按照解释方式执行,而编译动作则在后台编译线程中进行。
JVM大部分优化都是在JIT过程中,JIT产生的本地代码比Javac产生的字节码更加优秀。
守护内联 内联缓存【激进优化】
内联优化的好处:
1)去除方法调用的成本,如:建立栈桢。
2)方法内联后便可以在更大范围上采取后续的优化手段。
对于使用invokespecial指令调用的私有方法、实例构造器、父类方法,以及使用invokestatic指令进行调用的静态方法,再有被final修饰的方法,他们在编译期间进行解析。【但生成的class文件没有做内联优化】
-
虚方法的内联
为了解决虚方法的内联问题,JVM引入了一种【类型继承关系分析,Class Hierarchy Analysis,CHA】技术,它用于确定在目标已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类、子类是否为抽象类等信息。
编译器在进行内联时,如果是非虚方法,则直接进行内联,这个时候内联是有稳定保障的。如果遇到虚方法,则会向CHA查询此方法在当前程序下是否有多个目标版本。
if :如果只有一个版本,则也可以进行内联,不过这种内联属于激进优化,需要预留一个【逃生门】,称为【守护内联】。如果程序在后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联优化的代码可以一直使用下去。但如果加载了导致继承关系发生变化的类,那就需要抛弃已经编译的代码,退回到解释状态执行,或重新进行编译。
else :如果有多个版本的目标方法可供选择,此时编译器使用【内联缓存】 来完成方法内联,这是一个建立在目标方法入口之前的缓存。
工作原理:在未发生方法调用之前,内联缓存为空;当第一次调用发生后,内联缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者的版本,如果接收者的版本始终保持一致,那这个内联可以一直用下去;如果发生了方法接收者版本不一致的情况,说明程序真正在使用虚方法的多态特性,此时取消内联,查找【虚方法表】进行方法分派。
如此看来,虚拟机都会把方法优化为内联方法,只不过在有些情况下为激进优化,当激进优化被打破时,再改为解释状态执行,或重新编译,或取消内联。
逃逸分析
逃逸分析的基本行为就是分析对象动态作用域。
方法逃逸:当一个对象在方法中被定义后,它被外部【方法】所引用,如:作为调用参数传递出去。
线程逃逸:当一个对象在方法中被定义后,它被外部【线程】所访问,如:将该局部变量赋值给类变量,或赋值给可以在其他线程中访问的实例变量。
对非逃逸对象可进行的优化:
1)栈上分配
当一个对象不会逃逸出方法外,可以优化将对象内存在栈上进行分配,这样对象可以随着栈桢出栈而销毁,减小垃圾回收系统的压力。【HotSpot不支持】
2)同步消除
当变量不会逃逸出线程时,无法被其它线程访问到,可以将对这个变量实施的同步措施进行消除。
3)标量替换
标量:数据无法再分解成更小的数据来表示,即Java中的基本数据类型。
聚合量:数据可以再进一步进行分解,如:对象。
标量替换:把一个Java对象拆分,将其使用到的成员变量恢复为原始类型来访问。
如果一个对象不会逃逸,那程序真正执行的时候可能不创建这个对象,而改为直接创建它的成员变量。将对象拆分后,除了可以让对象的成员变量在栈上分配和读写之外,还可以为后续进一步的优化手段创造条件。
标量替换例子
package com.ljg;
public class T {
static class Person {
public String name;
public int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
}
public static void main(String[] args) {
Person person = new Person("ljg", 18);
System.out.println(person.name + "age is " + person.age);
}
}
上面代码可能优化为:【想不到的优化】
public static void main(String[] args) {
String name = "ljg";
int age = 18;
System.out.println(name + "age is " + age);
}
这样就不存在垃圾回收了。