什么是逃逸分析
所谓逃逸,包括方法逃逸和线程逃逸,线程逃逸的逃逸程度高于方法逃逸(线程逃逸 > 方法逃逸):
当一个对象在方法里面被定义后,它如果被外部方法所引用(例如作为调用参数传递到其他方法中),这种称为方法逃逸;
可能被外部其他线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;
this 引用逃逸就是一种线程逃逸:在构造器构造还未彻底完成前(即实例初始化阶段还未完成),将自身 this 引用向外抛出并被其他线程复制(访问)了该引用,那么其他线程就可能会访问到该还未被初始化的变量。
举个例子:
public class FinalReferenceEscapeTest {
final int i;
static FinalReferenceEscapeTest obj;
public FinalReferenceEscapeTest () {
i = 1; // 1. 写 final 域
obj = this; // 2. this 引用在此 "逸出"
}
// 线程 A
public static void writer() {
new FinalReferenceEscapeExample();
}
// 线程 B
public static void reader() {
if (obj != null) { // 3
int temp = obj.i; // 4
}
}
}
假设一个线程 A 执行 writer() 方法,另一个线程 B 执行 reader() 方法。这里的操作 2 将自身 this 引用向外抛出,使得 FinalReferenceEscapeTest 对象还未完成构造前就为其他线程可见。
有的同学可能会问,这个操作 2 不是在构造函数的最后一步吗,它执行完构造函数也执行完了,对象不就已经完成构造了吗?
But 这里的操作 1 和操作 2 之间可能被重排序。如下图所示,线程 B 不能正确地读到 i = 1,而是未初始化的 i = 0:
所以,我们可以得出这样的结论:在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的各个字段(域)可能还没有被初始化。
如果虚拟机能够确定一个对象不会发生方法逃逸和线程逃逸,或者逃逸程度比较低(只发生方法逃逸,不发生线程逃逸),则(JIT 即时编译器)可以为这个对象实例采取不同程度的优化,比如锁消除 Lock Elimination(也称为 “同步消除 Synchronization Elimination”)、还有 栈上分配(Stack Allocations) 和 标量替换(Scalar Replacement)等
栈上分配
栈上分配(Stack Allocations)是 JIT 即时编译器的一项优化技术:如果确定一个对象不会逃逸出线程之外(不发生逃逸或逃逸程度较低 - 方法逃逸),那让这个对象在栈(线程私有)上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。
在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多。
示例代码:
public class StackAllocationExample {
private static final int MAX = 10000000;
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < MAX; i++) {
allocateOnStack();
}
long end = System.currentTimeMillis();
System.out.println("Time taken: " + (end - start) + "ms");
}
private static void allocateOnStack() {
Point p = new Point();
p.x = 1;
p.y = 2;
}
private static class Point {
int x;
int y;
}
}
在这个示例代码中,我们定义了一个私有的静态内部类 Point,它包含两个 int 类型的成员变量 x 和 y。在 main 方法中,我们循环调用 allocateOnStack 方法,该方法内部创建一个 Point 对象并将其成员变量赋值为 1 和 2。由于 allocateOnStack 方法没有返回 Point 对象,换言之 Point 对象是不会被暴露给其他线程的,即不会发生线程逃逸,因此编译器可以将该对象分配在栈上而不是堆上。