对于前面的即时编译、提前编译的讲解,我们对代码的编译技术有了一定的了解。编译器的目标虽然是做由程序代码翻译为本地机器码的工作,但其实难点并不在于能不能成功翻译出机器码,输出代码优化质量的高低才是决定编译器优秀与否的关键。
本节将介绍几种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);
}
}
而对于这些逃逸程度,采取不同的优化:
未开启时的:
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
且对象的内存分布也是不一样的,一个是全是在堆区的,开启之后,一部分的对象会在栈上分配。
栈上分配只支持方法逃逸,而不支持线程逃逸。
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相关书籍。