首先抛出一个问题:堆是分配对象内存的唯一选择吗?;或者换种问法:所有的对象实例都在堆上分配内存吗?
既然这样问了,那显然不是!!(一定要看到最后,会有惊喜!)
随着 Java 的发展,现在已经能够看到些许迹象表明日后可能出现值类型的支持
即使只考虑现在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致一些微妙的变化悄然发生
所以说 Java 对象实例都分配在堆上也渐渐变得不是那么绝对了!!下面我们就来详细唠一唠「逃逸分析」
首先搞清楚什么是「逃逸」,根据这个词的本意大概可以知道:规定了一个范围,如果超出了这个范围,就叫逃逸!!(他逃,她追,他插翅难飞)
逃逸分析的基本原理:分析对象动态作用域,当一个对象在方法里面被定义后
根据上面的原理,可以把逃逸分为三种:不逃逸 -> 方法逃逸 -> 线程逃逸,其逃逸程度依次增高!
如果可以证明一个对象不会逃逸到方法或线程之外 (别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低 (只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化
下面为不同程度的逃逸举几个例子:
public class EscapeExample {
private StringBuilder sb;
// 没有发生逃逸
public void f1() {
StringBuilder s = new StringBuilder();
s.append("a");
}
// 发生方法逃逸 (通过参数传给其他方法)
public void f2() {
StringBuilder s = new StringBuilder();
s.append("a");
test(s);
}
private void test(StringBuilder s) {
s.append("ab");
}
// 发生方法逃逸 (通过返回值传给其他方法)
public StringBuilder f3() {
StringBuilder s = new StringBuilder();
s.append("a");
return s;
}
// 没有发生逃逸,返回的并不是对象 s,而是一个 String 对象,String 对象不可变
public String f4() {
StringBuilder s = new StringBuilder();
s.append("a");
return s.toString();
}
// 发生线程逃逸 (将方法内的对象赋值给实例变量)
public void f5() {
StringBuilder s = new StringBuilder();
s.append("a");
sb = s;
}
// 发生线程逃逸 (方法内调用了一个发生线程逃逸的方法)
public void f6() {
f5();
}
}
将一个对象的内存分配到堆上,意味着每次垃圾收集动作都会考虑该对象内存是否需要被回收,消耗大量资源
如果确定了一个对象不会逃逸到线程之外,那就可以考虑在栈上为该对象分配内存。由于栈是线程私有,如果确定了其他线程无法访问到该对象,那么栈上分配方案就是可行的
而且对象在栈上,其内存空间随着栈帧出栈而被销毁,无需垃圾收集的干预,大大的降低了资源的消耗
在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例还是很大的,如果能使用栈上分配,那大量对象就会随着方法的结束而自动销毁
注意:栈上分配可以支持方法逃逸、但不能支持线程逃逸
实战分析之前介绍几个参数:
-XX:+DoEscapeAnalysis
:手动开始逃逸分析 (只有开启了逃逸分析,才支持栈上分配)-XX:+PrintGCDetails
:输出详细的 GC 处理日志 (如果有 GC 的话)xxxxxxxxxx
// -Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
public class StackAllocation {
public static void main(String[] args) throws InterruptedException {
long start = System.currentTimeMillis();
// 调用 1e7 次 alloc() 方法
for (int i = 0; i < (int) 1e7; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start) + " ms");
Thread.sleep(10000000);
}
// 没有发生逃逸
private static void alloc() {
User user = new User();
}
static class User {}
}
// result
花费的时间为:32 ms
用 VisualVM 软件查看一下堆中内存的使用情况:
可以看到User
对象的内存占用最多,一共有 User
对象全部被分配到了堆上
总结:关闭逃逸分析,就算是程度最低的逃逸 (不逃逸),对象内存也不会在栈上分配
代码和上面的一样,唯一不同的是执行参数:-Xmx1G -Xms1G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
用 VisualVM 软件查看一下堆中内存的使用情况:
可以看到只有大概 User
对象分配在堆上,其余的都在栈上分配,时间也只花费了 2 ms
上面提到过:栈上分配可以支持方法逃逸,所以这种情况和上一种情况差不多
xxxxxxxxxx
public class StackAllocation {
// 实例变量
private static User user = null;
public static void main(String[] args) throws InterruptedException {
long start = System.currentTimeMillis();
for (int i = 0; i < (int) 1e7; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start) + " ms");
Thread.sleep(10000000);
}
// 发生线程逃逸
private static void alloc() {
// 赋值给实例变量
user = new User();
}
static class User { }
}
无论是否开启逃逸分析,对象都只会在堆上分配
当分别使用参数
-Xmx256m -Xms256m -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
-Xmx256m -Xms256m -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
重复上述第一、二种情况,即:「关闭逃逸分析 + 未发生逃逸」、「开启逃逸分析 + 未发生逃逸」
可以发现,未开启逃逸分析的情况,输出了 GC 处理日志,说明堆内存不足够分配给
xxxxxxxxxx
[GC (Allocation Failure) [PSYoungGen: 65536K->592K(76288K)] 65536K->600K(251392K), 0.0006336 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 66128K->592K(76288K)] 66136K->608K(251392K), 0.0006327 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
而开启了逃逸分析的情况,并没有输出 GC 处理日志,说明大部分对象都在栈上分配,堆内存足够分配给剩余的对象
标量:若一个数据无法再分解成更小的数据来表示,则称之为标量 (基本数据类型)
聚合量:若一个数据可以分解成更小的数据来表示,则称之为聚合量 (对象)
如果逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候就可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替
将对象拆分成标量后,除了可以让对象的成员变量在栈上 (栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储) 分配和读写之外,还可以为后续经一步的优化手段创建机会
注意:标量替换对逃逸程度要求更高,不允许对象逃逸出方法范围内
关系:标量替换可以视作栈上分配的一种特例,实现更简单
线程同步本身是一个相对耗时的过程,如果逃逸分析可以确定一个变量不会逃逸出线程,无法被其它线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以安全地消除
举个例子:(下面两种写法是等价的)
xxxxxxxxxx
public class SynElimination {
public int i = 0;
public void f1() {
Object obj = new Object();
synchronized (obj) {
i++;
}
}
// 👇👇👇👇👇👇👇
public void f2() {
i++;
}
}
由于obj
是一个方法内的局部变量,而且未发生逃逸,所以把它当作同步锁的对象,是没有作用的,f1()
和f2()
等价
通过一系列的伪代码的变化过程来模拟逃逸分析是如何工作的,初始代码如下所示:
xxxxxxxxxx
// 完全未优化的代码
public int test(int x) {
int xx = x + 2;
Point p = new Point(xx, 42);
return p.getX();
}
第一步:将Point
的构造函数和getX()
方法进行内联优化:
xxxxxxxxxx
// 步骤 1:构造函数内联后的样子
public int test(int x) {
int xx = x + 2;
Point p = point_memory_alloc(); // 在堆中分配 p 对象的示意方法
p.x = xx; p.y = 42; // Point 构造函数被内联后的样子
return p.x; // Point::getX() 被内联后的样子
}
第二步:经过逃逸分析,发现整个test()
方法的范围内Point
对象实例不会发生任何程度的逃逸,这样可以对它进行标量替换优化,把其内部的x
和y
直接置换出来,分解为test()
方法内的局部变量,从而避免了Point
对象实例被实际创建,优化后的结果如下所示:
xxxxxxxxxx
// 步骤 2:标量替换后的样子
public int test(int x) {
int xx = x + 2;
int px = xx;
int py = 42;
return px;
}
第三步,通过数据流分析,发现py
的值其实对方法不会造成任何影响,那就可以放心地去做无效代码消除得到最终优化结果,如下所示:
xxxxxxxxxx
// 步骤 3:做无效代码消除后的样子
public int test(int x) {
return x + 2;
}
本篇文章提到的三种优化方法 (栈上分配、标量替换、同步消除) 都是基于逃逸分析的前提,如果没有逃逸分析,那么这三个方法也就不成立了
只有准确的分析出对象是否发生逃逸,发生了什么程度的逃逸,才能使用栈上分配、标量替换、同步消除来优化
这也是难点所在,而且到现在这项优化技术还不成熟,主要原因是逃逸分析的计算成本非常高,甚至不能保证逃逸分析来带的性能收益会高于它的消耗
正是由于复杂度等原因,HotSpot 虚拟机中目前还没有做栈上分配这项优化,但一些其它的虚拟机 (如 Excelsior JET) 使用了这项优化
前文也说过:标量替换可以视作栈上分配的一种特例,实现更简单。所以对于 HotSpot 虚拟机,没有实现栈上分配,只实现了标量替换
由于本人使用的是 OpenJDK,其背后默认是 HotSpot 虚拟机,所以在介绍「栈上分配」部分时,那些效果其实都是「标量替换」在搞鬼
不信的话,可以使用参数-XX:-EliminateAllocations
把标量替换关了再看看效果,会发现所有对象都在堆上分配
现在回到文章开头的问题:所有的对象实例都在堆上分配内存吗?;开头给的答案是:不一定。这里从严谨的角度再次给出答案:所有的对象实例都在堆上分配内存!
虽然有栈上分配优化,但是 HotSpot 并未实现这项优化。在介绍 虚拟机栈 时,唯一能够存储数据的地方就是局部变量表,而它也只能够存储基本数据类型和引用类型,如果是一个对象,存放在哪里呢??
HotSpot 实现了标量替换优化,是因为标量替换优化将对象拆散成基本数据类型,可以存储在局部变量表中
(以上两句话的内容,均为本人胡扯,哈哈哈哈哈!!因为找不到更好的答案)
那为什么又说所有的对象实例都在堆上分配内存!对于 HotSpot,虽然有标量替换优化,但其本质也是将对象拆成标量,然后存储在栈上。所以 HotSpot 并非是直接把对象存储在栈上,而是来了一波曲线救国
这也和《Java 虚拟机规范》呼应上了:The heap is the run-time data area from which memory for all class instances and arrays is allocated.
翻译:所有的对象实例以及数组都应当在堆上分配
但但但这只是目前为止,谁又知道未来 HotSpot 会不会实现真正意义上的堆上分配呢??