一、基本原理与名词
逃逸分析是目前较前沿的优化技术,它不会进行代码的直接优化,而是为其他优化技术提供分析的技术。
原理
通过其对象动态作用域进行分析,从而得到逃逸程度。
方法逃逸
当一个对象在方法里面被定义后,它可能被外部方法所引用,作为调用参数传递到其他方法中。
线程逃逸
赋值到可以在其他线程中访问到的实例变量。
逃逸程度
逃逸程度从低到高分为三个级别:
- 不逃逸:其他方法或线程都无法通过任何途径访问到这个对象。
- 逃逸程度低:即方法逃逸,线程以内的逃逸。
- 逃逸程度高:即线程逃逸,可逃逸至线程外。
二、逃逸优化
1. 栈上分配
当确定一个对象不会逃逸出线程之外,直接让对象在栈上进行内存分配 即可,对象占用的内存会随栈帧出栈而销毁。
在实际应用开发中,不逃逸和逃逸程度低的对象所占比例是很大的,大量对象随着方法结束会自动销毁,垃圾收集的压力会大大减小。但此方式 不支持线程逃逸。
2. 标量替换
标量
一个数据已无法再分解成更小的数据来表示,那么就可以称它为标量。
例如:Java 虚拟机的原始数据类型(int
、long
等数值类型及 reference
类型等),都无法进一步进行分解。
聚合量:
一个数据可以继续分解,则为聚合量。
例如:Java 中的对象就是典型的聚合量。
标量替换
如果把一个对象(聚合量)拆散,根据程序访问情况,将用到的 成员变量恢复为原始类型(标量)来进行访问,此过程即为标量替换。
当确定一个对象不会被方法外部访问,并且这个对象可以被拆散,则程序执行时可能不会创建这个对象,由此带来两点好处:
- 对象可直接分配和读写在栈上,而栈上的数据很大机会被分配至物理机器的高速寄存器中存储。
- 为后续进一步优化创造条件。
但此方式要求更高,不允许对象逃逸出方法范围内。
3. 同步消除
线程同步本身是一个相对耗时的过程,当确定一个变量不会逃逸出线程时(其他线程无法访问),此变量读写肯定不会有竞争,对这个变量进行的 同步措施可以安全消除。
4. 工作过程示例
初始代码
class Point {
private int x;
private int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
public int test(int x) {
int xx = x + 2;
Point p = new Point(xx, 42);
}
内联化
将 Point
的构造函数和 getX()
方法进行内联优化(参考内联相关博客)。
public int test(int x) {
int xx = x + 2;
Point p = point_memory_alloc(); // 在堆上分配 p 对象的表示(非真实代码)
p.x = xx; // ```Point``` 的构造函数内联后的表示(非真实代码)
p.y = 42;
return p.x; // ```Point::getX()``` 被内联后的表示(非真实代码)
}
逃逸分析(标量替换)
整个 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;
}
三、实验(栈上分配验证)
测试代码
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
alloc();
}
// 查看执行时间
long end = System.currentTimeMillis();
System.out.println("cost " + (end - start) + " ms");
// 为了方便查看堆内存中对象个数,线程 sleep
try {
Thread.sleep(600000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
private static void alloc() {
User user = new User();
}
static class User {
}
分析
代码内容很简单,使用 for
循环,创建 100 万个 User
对象。
其中,alloc
方法中定义了 User
对象,但是并没有在方法外部引用。故这个对象并不会逃逸到 alloc
外部。经过 JIT 的逃逸分析之后,就可以对其内存分配进行优化。
参数设定
- 第一组
指定以下 JVM 参数:
-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
其中-XX:-DoEscapeAnalysis
表示 关闭 逃逸分析。 - 第二组
指定以下 JVM 参数:
-Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
其中-XX:+DoEscapeAnalysis
表示 开启 逃逸分析。
运行结果
分别使用两组参数运行代码。
在程序打印出 cost XX ms 后,代码运行结束之前,我们使用 jmap
命令,来查看下当前堆内存中有多少个 User
对象。
> jmap -histo 2809 // 其中 2809 为当前 JVM 进程 ID
- 第一组
num #instances #bytes class name
----------------------------------------------
1: 524 87282184 [I
2: 1000000 16000000 StackAllocTest$User
3: 6806 2093136 [B
4: 8006 1320872 [C
5: 4188 100512 java.lang.String
6: 581 66304 java.lang.Class
- 第二组
num #instances #bytes class name
----------------------------------------------
1: 524 101944280 [I
2: 6806 2093136 [B
3: 83619 1337904 StackAllocTest$User
4: 8006 1320872 [C
5: 4188 100512 java.lang.String
6: 581 66304 java.lang.Class
分析
从上面的 jmap
执行结果中我们可以看到。
- 第一组
堆中共创建了 100 万个StackAllocTest$User
实例。 - 第二组
堆中共创建了 8.3 万多个StackAllocTest$User
实例。
结论
在关闭逃避分析的情况下,虽然在 alloc
方法中创建的 User
对象并没有逃逸到方法外部,但是还是被分配在堆内存中。
故没有 JIT 编译器优化,没有逃逸分析技术,所有对象都分配到堆内存中。
在打开逃避分析的情况下,在堆内存中只有 8 万多个 StackAllocTest$User
对象。也就是说在经过 JIT 优化之后,堆内存中分配的对象数量,从 100 万降到了 8.3 万。
除以上通过 jmap
验证对象个数的方法以外,还可以尝试将堆内存调小,然后执行以上代码,根据GC的次数来分析。
也能发现,开启了逃逸分析之后,在运行期间,GC 次数会明显减少。因为很多堆上分配对象内存被优化至栈上分配,随着方法结束而自动销毁,导致 GC 次数减少。
四、总结
发展与现状
关于逃逸分析的论文在 1999 年就已经发表了,但直到 JDK 1.6,HotSpot 才初步支持逃逸分析实现,而且这项技术到如今也并不是十分成熟的,仍有很大的改进余地。
根本原因
无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配和同步消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
举一个极端的例子,经过逃逸分析之后,发现没有一个对象是不逃逸的,那这个逃逸分析的过程就白白浪费掉了。故目前虚拟机只能采用不那么准确,但时间压力相对小的算法。
虽然这项技术并不十分成熟,但是他也是即时编译器优化技术中一个十分重要的手段。