一、背景
随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么绝对了
在JAVA虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识,但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象没有逃逸出方法的话,那么就可能被优化成栈上分配.这样就无需在堆上分配内存,也无须进行垃圾回收,这也是最常见的堆外存储技术
逃逸分析技术到现在还不是很成熟,虽然经过逃逸分析可以做标量替换、栈上分配、锁消除.但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程
二、逃逸分析
逃逸分析是一种数据分析算法,基于此算法可以有效减少Java对象在堆内存中的分配,Hotspot虚拟机的编译器能够分析出一个新对象的引用范围,然后决定是否要将这个对象分配到堆上,例如:
当一个对象在方法内部被定义(创建)后,对象只在方法内部使用,则认为没有发生逃逸
当一个对象在方法内部被定义(创建)以后,它被方法外部所引用,则认为发生逃逸
三、逃逸分析案例
1.逃逸对象
如下对象point在main方法被定以后,被alloc方法引用,则发生了逃逸(对象逃逸出了方法):
public class ObjectScalarReplaceTests {
public static void main(String args[]) throws InterruptedException {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
}
static Point point;
private static void alloc() {
point = new Point(1,2);
}
static class Point {
private int x;
private int y;
public Point(int x,int y){
this.x=x;
this.y=y;
}
}
}
2.未逃逸对象
和上面的代码差不多,区别在于对象是在方法中定义,在方法中引用(对象没有逃逸出方法):
public class ObjectScalarReplaceTests {
public static void main(String args[]) throws InterruptedException {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
}
private static void alloc() {
Point point = new Point(1,2);
//如上对象标量替换为
//int x=1;//局部变量是在栈上分配的
//int y=2;
}
static class Point {
private int x;
private int y;
public Point(int x,int y){
this.x=x;
this.y=y;
}
}
可以对比这两段代码的输出结果,即运行时间,显然下面这段代码的运行时间更短:这是因为逃逸对象,只能分配在堆中,所以随着程序的运行,会触发GC(可通过选项:"-XX: PrintGC",可以在控制台看到GC工作),而一旦触发GC,则需等待GC工作结束后继续执行程序(方法),所以执行时间较长
3.逃逸分析参数设置
在JDK1.7版本之后,HotSpot中默认就已经开启了逃逸分析,如果使用的是较早的版本,开发人员则可以通过:
选项"-XX: +DoEscapeAnalysis"显示开启逃逸分析
通过选项"-XX: +PrintEscapeAnalysis"查看逃逸分析的筛选结果
四、代码优化实践
1.栈上分配
将堆分配转化为栈分配,如果一个对象在方法内创建,要使指向该对象的引用不会发生发生逃逸,对象可能是栈上分配的首选(如上面两段代码)
2.同步锁消除
我们知道线程同步是靠牺牲性能来保护数据的正确性,这个过程的代价会非常高,程序的并发性和性能都会降低,JVM的JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程应用?假如是,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码加上的锁,这个取消同步的过程就叫同步省略,也叫锁消除,例如:
public class SynchronizedLockTest {
public void lock() {
Object obj= new Object();
synchronized(obj) {
System.out.println(obj);
}
}
}
3.标量替换分析
所谓的标量(scalar)一般指的是一个无法再分解成更小数据的数据:例如 ,Java中的原始数据类型就是标量.相对的,那些还可以分解的数据叫做聚合量(Aggregate), Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量. 在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过 JIT 优化,就会把这个对象分解成若干个 变量来代替. 这个过程就是标量替换
如本博客的第二段代码,假如开启了标量替换,那么alloc方法的内容就会变为如下形式:
private static void alloc() {
int x=10;
int y=20;
}
alloc 方法内部的 Point 对象是一个聚合量,这个聚合量经过逃逸分析后,发现他并 没有逃逸,就被替换成两个标量了. 那么标量替换有什么好处呢?就是可以大大减少堆内存 的占用. 因为一旦不需要创建对象了,那么就不再需要分配堆内存了. 标量替换为栈上分配 提供了很好的基础
总结
1.开发中能在方法内部应用对象的,就尽量控制在内部,这样对象可能是栈上分配的候选
2.分享一道面试题:Java中所有的对象创建都是在堆上分配内存的:
答:随着技术的升级,这个说法现在不准确了,经过逃逸分析后,未逃逸对象也有可能被分配到栈上
3.并不是所有的未逃逸对象都能分配到栈上,因为栈内存也有限,内存较小的对象有可能被分配到栈上