1. 编译期优化
1.1 javac的编译过程:
整个过程总结起来就是对源码进行词法分析,语法分析,注解处理,语义分析,解语法糖生成字节码。以下是主体代码实现:
1.2 Java语法糖
语法糖指在计算机语言中添加的某种语法,这种语法对语言的功能无影响,但更方便使用,增加程序可读性,类似于一种
1.2.1 泛型与类型擦出
泛型的本质是参数化类型的应用,也就是说操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类,泛型接口和泛型方法。
但在java中的泛型称为伪泛型,因为java泛型仅在源代码中存在,编译后的字节码就已经替换为原来的原生类型,并且在相应的地方插入了强制转型代码。他不像C#的泛型,在源码,编译后的IL及运行期的CLR中都切实存在。例如参数List和List在编译之后变成一样的原生类型List,擦除动作导致这两种方法的特征签名变得一样。另外,java代码中的方法特征签名只包含方法名称,参数顺序及参数类型。字节码中的特征前面还包括方法返回值及受查异常表。
1.2.2 自动装箱的陷阱
public static void main(String args[]) {
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g = 3L;
int x = 3;
long y = 3L;
// x,y虽然类型不同但是可以直接进行数值比较
System.out.println(x == y);
//System.out.println(c == g);运行出错,比较拆箱前先判断类型是否相同,类型不同不拆箱
// c,d均引用常量缓冲池的地址,true
System.out.println(c == d);
// 321 > 127 是在堆中new出的,地址不同。fasle
System.out.println(e == f);
// ==遇到算数运算自动拆箱比较值,true
System.out.println(c == (a+b));
// 包装类类型和值均一样,true
System.out.println(c.equals(a+b));
// 包装类 == 遇算数运算,自动拆箱,比较值,true
System.out.println(g == (a+b));
// 类型不同,fasle
System.out.println(g.equals(a+b));
}
以上注释的判断是基于一些规则而定的,总结如下:
- 基本数据类型除String外,==,equals比较的是值
- 复合数据类型,== 比较堆内存地址,equals先比较类型再比较值
- 特定的基本类型会被包装以实现告诉缓存的复用,包括boolean值,byte值,-128到127的short,int值,介于\u0000~\u007F的char值。
- 包装类的==运算不遇到算数运算时不自动拆箱;包装类equals方法不处理数据转型。
2. 运行期优化
2.1 HotSpot虚拟机内的即时编译器
首先对于这节内容明确几个宏观的问题:
- 为何HotSpot虚拟机要使用解释器和编译器并存的架构?
答:当程序需要迅速启动和执行时,解释器省去编译的时间,立即执行;程序运行后,随着时间的推移,编译器把代码编译为本地代码可获得更高的执行效率。也就是说,解释器启动快,无需消耗编译时间,但随着运行时间的增加,编译的本地代码更偏底层,效率优势就愈发明显。 - 为何HotSpot虚拟机要实现两个不同的即时编译器?
答:用C1编译器进行简单可靠的优化,以获得更高的编译速度;用C2编译器进行复杂优化获取更好的编译质量。 - 哪些程序会被编译为本地代码?
答:热点代码(包括被调用多次的代码以及包含多次执行循环体的代码)会被编译为本地代码
2.1.1 解释器与编译器
解释器可以首先发挥作用,省去编译的时间,立即执行。程序运行后,随着时间推移编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获得更高的执行效率。当程序运行环境中内存限制较大时,可以使用解释执行节约内存,反之可以使用编译执行提高效率。解释器与编译器的交互如下图:
为了在程序启动相应速度与运行效率之间达到最佳平衡,HotSpot虚拟机逐渐启用分层编译。分层编译根据编译器编译,优化的规模与耗时,划分出不同的编译层次:
-
第0层
程序解释执行,解释器不开启性能监控功能,可触发第1层编译 -
第1层
也称为C1编译,将字节码编译为本地代码,进行简单,可靠的优化,如有必要将加入性能监控的逻辑 -
第2层
也称C2编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
2.1.2 编译对象与触发条件
运行过程中还会被即时编译器编译的“热点代码”有两类:
-
被多次调用的方法(产生即时编译)
-
包含被多次执行的循环体的方法(产生OSR编译)
目前主要的热点探测判定方式有两种: -
基于采样的热点探测
虚拟机周期性的检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那这些方法就是"热点方法"。缺点是不能确定一个方法的热度。 -
基于计数器的热点探测
虚拟机会为每个方法建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是"热点方法"。
在HotSpot中使用基于计数器的热点探测,它为每个方法准备了方法调用计数器和回边计数器,分别统计方法被调次数以及一个方法中循环体代码执行的次数,很明显回边计数器为了触发OSR编译。当超过一定的时间限度,如果方法的调用次数仍然不足以让他提交给即时编译器编译,则这个方法的调用计数器会被减半,该过程被称为计数器热度的衰减。回边计数器无热度衰减操作
以C1编译器为例,编译过程无非是对输入的字节码做相关优化输出高级中间代码表示(HIR),再做优化输出低级中间代码表示(LIR),在此基础上再做相应的优化输出本地代码。
2.2 编译优化技术
代码优化变换是建立在代码的中间表示或者机器码之上,而非源码上。一般首先进行的优化是方法内联优化。主要目的是减少方法调用成本(如建立栈帧等)以及为其他优化建立基础,因为方法内联膨胀后可便于在更大范围上采取后续优化手段。例如进行冗余存储消除优化,复写传播优化,无用代码消除优化等。
原始代码
static class B {
int value;
final int get() {
return value;
}
}
public void foo() {
y = b.get();
z = b.get();
sum = y + z;
}
内联优化后
public void foo() {
// 去除方法调用成本,为其他优化建立基础
y = b.value;
z = b.value
sum = y + z;
}
冗余存储消除优化后
public void foo() {
y = b.value;
// 减少访问对象b的局部变量
z = y
sum = y + z;
}
复写传播优化后
public void foo() {
y = b.value;
// 减少额外变量的开辟
y = y
sum = y + y;
}
无用代码消除优化后
public void foo() {
y = b.value;
// 消除无用代码,减少使用指令的数量
sum = y + y;
}
2.2.1 公共子表达式消除优化
如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有变化过,那E的这次出现就成了公共子表达式。对于这种表达式没有必要再花时间计算直接用前面的结果替换
2.2.2 数组边界检查消除
对于Java语言访问数组元素foo[i]时,系统将会自动进行上下界的范围检查,即判断i必须满足i >= 0 && i < foo.length。每次数组的读写都要进行检查会增加运行负担。因此,编译器只要通过数据流分析就可以判断循环变量永远不会溢出,那就可以消除数组的上下界检查。例如只要通过数据流分析foo.length的值,并判断下标“3”没有越界,执行的适合就无需判断了。
2.2.3 方法内联
正如上述所知,方法内联能够消除方法调用的成本并且能为其他优化手段建立良好的基础。方法内联无非是把目标方法的代码“复制”到发起调用的方法中,问题是如果目标方法是虚方法则无法进行多态选择,即无法确定目标方法是哪个方法。为了解决虚方法的内联,可以使用"类型继承关系分析"(CHA)技术,遇到虚方法会向CHA查询此方法在当前程序下是否有多个目标版本可供选择,如果只有一个版本则可以内联,如果CHA查询出来的结果有多个版本,则编译器还可以使用内联缓存来完成方法内联。
2.2.4 逃逸分析
分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,称为方法逃逸;甚至还有可能被外部线程访问到,称为线程逃逸。如果能证明一个对象不会逃逸到方法或线程之外,则可以为这个变量进行一些高效的优化:
- 栈上分配
将对象在栈上分配,他所占用的空间将会随栈帧出栈而销毁,减少GC压力。 - 同步消除
因为判断了线程私有,因此不存在线程的竞争,也就无需同步操作。 - 标量替换
标量一般指原始数据类型这种无法再进一步分界的单位量,对象是聚合量。标量替换的思想是用多个标量替换对象,完成方法的逻辑,这样就可以避免创建这个对象了。