Radio

一个小小程序员

0%

逃逸分析

说起逃逸分析(Escape Analysis),我们可能会觉得很陌生,之所以陌生的原因是因为这项技术还不是很成熟,其内部分析过程的复杂度也超乎我们的想象,早在JDK6,HotSpot就已经支持了逃逸分析,由于逃逸分析的计算成本之高,甚至不能保证带来的性能优化收益高于它的消耗。一直到JDK7,这项优化配置才被默认打开。

首先我们可以明确逃逸分析是一项优化技术。也许你曾经听说过,但凡new出来的对象肯定都在堆上,但是有了逃逸分析之后,就不一定了。

其优化原理是:分析对象的动态作用域,当一个对象在函数里面定义后,它可能会被外部的函数所引用,例如对象返回值作为参数再传递到其他函数中,这种对象逃出了自己函数的行为,称为方法逃逸;再有除了被别的函数引用外,可能还会被别的线程所访问,比如可以赋值给其他线程中访问的实例变量,这种称为线程逃逸。从不逃逸,方法逃逸到线程逃逸,我们称为对象由低到高的不同程度逃逸。

如果一个对象不会逃逸到方法或者线程外,换句话说别的方法或线程无法通过任何途径访问到这个对象,或者逃逸程度比较低(只逃出了方法,但是没有逃出线程),那么可能会对这个对象进行不同程度的优化:

栈上分配

在java虚拟机中,创建的对象都被分配在java堆中,堆中的对象是各个线程共享和可见的。等到没人引用对象后,虚拟机的垃圾回收子系统就会回收该对象,但是无论是标记筛选还是标记整理,垃圾回收都需要消耗大量的资源。如果我们可以确定一个对象不会逃逸出线程,那么这个对象在栈上分配也是一个不错的选择。对象所占用的内存就会随着栈帧出栈而销毁。一般情况下,这种不逃逸的情况所占比例是非常大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而销毁了,垃圾收集的压力也会下降很多。这种分配方式支持方法逃逸,但是不支持线程逃逸。

比如:

1
2
3
4
5
6
//这种情况,对象就是没有逃逸出方法
public void getPoint(){
Point point = new Point();
point.setX(1);
point.setY(1);
}
1
2
3
4
5
6
7
//这种情况,对象就逃逸出方法了
public Point getPoint(){
Point point = new Point();
point.setX(1);
point.setY(1);
return point;
}
标量替换

如果一个数据已经无法再分成更小的数据来表示了,比如原始类型(int,long等数值类型以及reference类型等)都不能再分解了,这种数据就可以称为标量。相对的,如果一个数据还可以被分解,那就是聚合量。对象就是一个典型的聚合量。如果我们根据程序情况,把一个对象分解,将用到的成员变量都替换为原始类型,这个过程就称为标量替换。如果一个对象没有逃逸出方法,而且还可以被分解,那么我们程序在执行过程中就可以不去创建这个对象。标量替换可以被视作栈上分配的一种特例,他要求对象不能逃逸出方法外。

比如:

1
2
3
4
5
6
7
//这种情况,就可以使用标量替换。
public String getPoint(){
Point point = new Point();
point.setX(1);
point.setY(1);
reutn "x:"+point.getX()+";y:"+point.getY();
}
1
2
3
4
5
6
//这种情况,就可以使用标量替换。
public String getPoint(){
int x = 1;
int y = 1;
reutn "x:"+point.getX()+";y:"+point.getY();
}
同步消除

如果我们可以确定一个变量不会逃逸出线程,即线程私有,那么这个变量的读写肯定就不会有竞争,对这个变量实施同步的措施也就可以安全的消除掉。

在这里我们引用一下锁消除的相关知识点。我们看下面这个代码:

1
2
3
public String concatStr(String s1, String s2, String s3, String s4) {
return s1 + s2 + s3 + s4;
}

现在我们都已经知道,由于String是个final类型的不可变类。对字符串的操作总是会生成出新的String对象来进行的。因为javac会对String的字符串操作进行优化,加法会自动转为StringBuilder对象的连续append()操作。但是在JDK5之前,并不是这样的,这个操作会被转成StringBuffer。即下面这样的代码

1
2
3
4
5
6
7
8
public String concatString(String s1, String s2, String s3, String s4) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
sb.append(s4);
return sb.toString();
}

这段代码其实是有同步的,即每一次append都会对sb进行锁定,经过逃逸分析后,我们发现sb的引用其实根本无法逃出线程,所以,这个地方虽然是有锁的,但是这个锁是完全没有必要的。在解释执行时这里仍然会加锁,但在经过服务端编译器的即时编译之后,这段代码就会忽略所有的同步措施而直接执行。

这里我们在扩展一个知识点,对于StringBuffer的append,如果频繁的进行互斥同步操作,甚至加锁操作在循环内部,这会导致很多不必要的性能消耗。所以虚拟机会对锁进行粗化,就是扩展到第一个append操作之前直至最后一个append操作之后,这样只需要加锁一次就可以了。

最后我们说一下逃逸分析的历史

引自深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)(周志明)

关于逃逸分析的研究论文早在1999年就已经发表,但直到JDK 6,HotSpot才开始支持初步的逃逸分析,而且到现在这项优化技术尚未足够成熟,仍有很大的改进余地。不成熟的原因主要是逃逸分析的计算成本非常高,甚至不能保证逃逸分析带来的性能收益会高于它的消耗。如果要百分之百准确地判断一个对象是否会逃逸,需要进行一系列复杂的数据流敏感的过程间分析,才能确定程序各个分支执行时对此对象的影响。前面介绍即时编译、提前编译优劣势时提到了过程间分析这种大压力的分析算法正是即时编译的弱项。可以试想一下,如果逃逸分析完毕后发现几乎找不到几个不逃逸的对象,那这些运行期耗用的时间就白白浪费了,所以目前虚拟机只能采用不那么准确,但时间压力相对较小的算法来完成分析。

C和C++语言里面原生就支持了栈上分配(不使用new操作符即可),而C#也支持值类型,可以很自然地做到标量替换(但并不会对引用类型做这种优化)。在灵活运用栈内存方面,确实是Java的一个弱项。在现在仍处于实验阶段的Valhalla项目里,设计了新的inline关键字用于定义Java的内联类型,目的是实现与C#中值类型相对标的功能。有了这个标识与约束,以后逃逸分析做起来就会简单很多。

对于栈上分配这种优化,由于复杂度等原因,HotSpot中目前暂时还没有做这项优化,但一些其他的虚拟机(如Excelsior JET)使用了这项优化。

尽管目前逃逸分析技术仍在发展之中,未完全成熟,但它是即时编译器优化技术的一个重要前进方向,在日后的Java虚拟机中,逃逸分析技术肯定会支撑起一系列更实用、有效的优化技术。

欢迎关注我的其它发布渠道