编译器优化技术
对于前面的即时编译、提前编译的讲解,我们对代码的编译技术有了一定的了解。编译器的目标虽然是做由程序代码翻译为本地机器码的工作,但其实难点并不在于能不能成功翻译出机器码,输出代码优化质量的高低才是决定编译器优秀与否的关键。
本节将介绍几种HotSpot虚拟机的即时编译器在生成代码时采用的代码优化技术
一、优化技术概览
不同的虚拟机有自己的优化技术,一个虚拟机的优化技术的好坏决定了一个虚拟机的好坏,对于我们常用的Hotspot虚拟机的常见优化技术概览如下:
我们来一些实际的java代码来实现一些代码优化技术:
package bio.optimization;
/*
PACKAGE_NAME:bio.optimization
USER:18413
DATE:2021/11/7 20:30
PROJECT_NAME:BIO
面向代码面向君,不负代码不负卿————蒋明辉 */
public class TestOptimization {
static class B{
int value;
final int get(){
return value;
}
}
public void foo() {
B b = new B();
int y = b.get();
// ...do stuff...
int z = b.get();
int sum = y + z;
}
}
对于上述代码,还是有很多的优化空间,首先就是方法的内联,它的目的有两个,一是省去方法的调用的成本(查找方法版本、方法入栈、建立栈帧等等),二是为其他的优化建立良好的基础
内联优化后的代码:
public void foo() {
y = b.value;
// ...do stuff...
z = b.value;
sum = y + z;
}
第二部就是进行冗余访问消除,消除代码间的冗余访问的过程,例如上述代码的value被重复访问,优化后打代码如下:
public void foo() {
B b = new B();
int y = b.value;
int z=y;
int sum=y+z;
}
第三部就是复写传播,我们在上述的代码发现,z和y的值相同,我们没必要是用新的变量去存储,所以优化后的代码如下:
public void foo() {
B b = new B();
int y = b.value;
y=y;
int sum=y+y;
}
第四部就是无用代码的消除,上述的代码我们发现y=y的这一步显然显得十分啰嗦,脱裤子放屁多此一举,所以优化后的代码如下:
public void foo() {
B b = new B();
int y = b.value;
int sum=y+y;
}
对于其他的代码优化技术,在此就不做过多的描述,我们作为开发者,并不需要深刻去了解这个功能,而对于一些很重要的代码优化技术,例如:
- 最重要的代码优化技术——方法内联
- 最前沿的代码优化技术——逃逸分析
- 语言无关的代码优化技术——公共子表达式消除
- 语言相关的经典优化技术——数组边界检查消除
二、方法内联
其实我们在上面就简单的描述了方法内联技术,他是最重要的代码优化技术,没有之一,除了优化代码结构外,它的更重要的目的是为了下面的代码优化步骤打好良好的基础。没有内联技术,其他的很多优化都无法有效的进行下去。
我们用代码来解释:
public static void foo(Object obj) {
if (obj != null) {
System.out.println("do something");
}
}
public static void testInline(String[] args) {
Object obj = null;
foo(obj);
}
方法内联的优化行为理解起来是没有任何困难的,不过就是把目标方法的代码原封不动地“复
制”到发起调用的方法之中,避免发生真实的方法调用而已。但实际上Java虚拟机中的内联过程却远没
有想象中容易,甚至如果不是即时编译器做了一些特殊的努力,按照经典编译原理的优化理论,大多
数的Java方法都无法进行内联。
对于虚方法的内联技术,以后做详细讲解。
三、逃逸分析
逃逸分析是最前沿的代码优化技术,他并不是对代码的结构进行优化,而是其他手段对代码进行优化,它的基本原理就是:分析对象的动态作用域,
- 当一个对象定义在方法内,它可能被外部方法引用,例如作为参数传递到其他方法,这种称为方法逃逸。
- 甚至会被外部线程访问,这种叫线程逃逸
public class EscapeTest {
public static Object globalObj;
// 给全局变量赋值,发生逃逸
public void globalVariableEscape() {
globalObj= new Object();
}
// 方法返回值,发生逃逸
public Object methodEscape() {
return new Object();
}
// 实例引用发生逃逸
public void instanceEscape() {
test(this);
}
}
而对于这些逃逸程度,采取不同的优化:
- 栈上分配:Java堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问到堆中存储的对象数据。虚拟机的垃圾收集子系统会回收堆中不再使用的对象,但回收动作无论是标记筛选出可回收对象,还是回收和整理内存,都需要耗费大量资源。如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不能支持线程逃逸。
我们可以使用参数开启栈上分配:
代码实现:-XX:+DoEscapeAnalysis
未开启时的:
package bio.optimization;
/*
PACKAGE_NAME:bio.optimization
USER:18413
DATE:2021/11/7 21:22
PROJECT_NAME:BIO
面向代码面向君,不负代码不负卿————蒋明辉 */
public class TestEscapeNo {
public static void main(String[] args) {
long l1=System.currentTimeMillis();
for (int i = 0; i < 2000000000; i++) {
foo();
}
long l2=System.currentTimeMillis();
System.out.println(l2-l1+"ms");
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
static void foo(){
byte[] bytes = new byte[2];
bytes[0] = 1;
bytes[1] = 1;
}
}
结果是:74ms
开启之后:4ms
且对象的内存分布也是不一样的,一个是全是在堆区的,开启之后,一部分的对象会在栈上分配。
栈上分配只支持方法逃逸,而不支持线程逃逸。
- 标量替换:若一个数据已经无法再分解成更小的数据来表示了,Java虚拟
机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据就可以被称为标量。相对的,如果一个数据可以继续分解,那它就被称为聚合量(Aggregate),Java中的对象就是典型的聚合量。如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。 - 同步消除:线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以安全地消除掉。
对于同步消除,我们可以用代码来演示一下:
void foo(int a){
System.out.println(a);
}
void doo(int b){
Object o = new Object();
synchronized (o){
System.out.println(b);
}
}
我们对于这些逃逸分析的实现有了一定的了解,下面我们使用代码来实现这些步骤:
Point类:
package bio.optimization;
/*
PACKAGE_NAME:bio.optimization
USER:18413
DATE:2021/11/7 21:34
PROJECT_NAME:BIO
面向代码面向君,不负代码不负卿————蒋明辉 */
public class Point {
int xx;
int yy;
public int getXx() {
return xx;
}
public void setXx(int xx) {
this.xx = xx;
}
public int getYy() {
return yy;
}
public void setYy(int yy) {
this.yy = yy;
}
public Point(int xx, int yy) {
this.xx = xx;
this.yy = yy;
}
}
完全没优化的代码如下:
public int test(int x) {
int xx = x + 2;
Point p = new Point(xx, 42);
return p.getXx();
}
第一步进行内联优化:
public int test(int x) {
int xx = x + 2;
Point p = new Point();
p.xx=xx;
return p.xx;
}
第二步经过逃逸分析,发现在整个test()方法的范围内Point对象实例不会发生任何程度的逃逸,
这样可以对它进行标量替换优化,把其内部的x和y直接置换出来,分解为test()方法内的局部变量,从
而避免Point对象实例被实际创建,优化后的结果如下所示:
public int test(int x) {
int xx = x + 2;
int px=xx;
int py=42;
return px;
}
第三步,我们发现py对程序毫无影响,所以优化后的最终代码为:
public int test(int x) {
return x+2;
}
四、公共子表达式消除
它是一款非常经典的,适用于很多虚拟机的代码优化,它的含义是:如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替E。
我们使用代码来实现:
package bio.optimization;
/*
PACKAGE_NAME:bio.optimization
USER:18413
DATE:2021/11/7 21:49
PROJECT_NAME:BIO
面向代码面向君,不负代码不负卿————蒋明辉 */
public class TestPublic {
public static void main(String[] args) {
int a=1;
int b=1;
int c=1;
int d = (c * b) * 12 + a + (a + b * c);
}
}
字节码指令如下:
0 iconst_1
1 istore_1
2 iconst_1
3 istore_2
4 iconst_1
5 istore_3
6 iload_3
7 iload_2
8 imul
9 bipush 12
11 imul
12 iload_1
13 iadd
14 iload_1
15 iload_2
16 iload_3
17 imul
18 iadd
19 iadd
20 istore 4
22 return
当这段代码进入虚拟机即时编译器后,它将进行如下优化:编译器检测到cb与bc是一样的表达
式,而且在计算期间b与c的值是不变的。
所以这段代码可以看为下面这段代码:
package bio.optimization;
/*
PACKAGE_NAME:bio.optimization
USER:18413
DATE:2021/11/7 21:49
PROJECT_NAME:BIO
面向代码面向君,不负代码不负卿————蒋明辉 */
public class TestPublic {
public static void main(String[] args) {
int a=1;
int b=1;
int c=1;
int d = (E) * 12 + a + (a + E);
}
}
还可能进行下面这种优化:
package bio.optimization;
/*
PACKAGE_NAME:bio.optimization
USER:18413
DATE:2021/11/7 21:49
PROJECT_NAME:BIO
面向代码面向君,不负代码不负卿————蒋明辉 */
public class TestPublic {
public static void main(String[] args) {
int a=1;
int b=1;
int c=1;
int d = (E) * 13 + a + a;
}
}
对于详细的代码优化技术,可以参考编译原理相关的书籍,这里就不做过多的阐述了。
五、数组边界消除
我们知道java语言不像c和c++语言靠指针进项操作,假如存在一个数组arr[],编译器会自动的数组的长度去判断。
这对软件开发者来说是一件很友好的事情,即使程序员没有专门编写防御代码,也能够避免大多数的溢出攻击。但是对于虚拟机的执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这必定是一种性能负担。
无论如何,为了安全,数组边界检查肯定是要做的,但数组边界检查是不是必须在运行期间一次
不漏地进行则是可以“商量”的事情。例如下面这个简单的情况:数组下标是一个常量,如arr[3],只要
在编译期根据数据流分析来确定foo.length的值,并判断下标“3”没有越界,执行的时候就无须判断了。
我们使用伪代码的形式来解释:
if (arr != null) {
return arr.value;
}else{
throw new NullPointException();
}
隐式优化后的代码:
try {
return arr.value;
} catch (segment_fault) {
uncommon_trap();
}
虚拟机会注册一个Segment Fault信号的异常处理器(伪代码中的uncommon_trap(),务必注意这里是指进程层面的异常处理器,并非真的Java的try-catch语句的异常处理器),这样当foo不为空的时候,对value的访问是不会有任何额外对foo判空的开销的,而代价就是当foo真的为空时,必须转到异常处理器中恢复中断并抛出NullPointException异常。进入异常处理器的过程涉及进程从用户态转到内核态中处理的过程,结束后会再回到用户态,速度远比一次判空检查要慢得多。当foo极少为空的时候,隐式异常优化是值得的,但假如foo经常为空,这样的优化反而会让程序更慢。幸好HotSpot虚拟机足够聪明,它会根据运行期收集到的性能监控信息自动选择最合适的方案。
总结
对于一些比较详细的编译器的代码优化技术介绍,详细的介绍我们可以去浏览相关书籍,推荐编译原理,周志明老师的JVM相关书籍。