Java对象竟然会在栈上分配内存?
共 3214字,需浏览 7分钟
·
2021-03-21 22:20
点击上方“JavaEdge”,关注公众号
1 逃逸分析
JVM中高深的优化技术,如同类继承关系分析,该技术并非直接去优化代码,而是一种为其他优化措施提供依据的分析技术。
分析对象的动态作用域,当某对象在方法里被定义后,它可能
方法逃逸
被外部方法引用,例如作为参数传递给其他方法
线程逃逸
被外部线程访问,例如赋值给可以在其他线程中访问的实例变量
所以 Java 对象由低到高的逃逸程度即为:
不逃逸 =》
方法逃逸 =》
线程逃逸
若能确定一个对象
不会逃逸到方法或线程外(即其它方法、线程无法访问到该对象)
或逃逸程度较低(只逃逸出方法而不逃逸出线程)
则可为该对象实例采取不同程度的优化方案。
2 优化方案
2.1 栈上分配(Stack Allocations)
由于复杂度等原因,HotSpot中目前暂时还没有做这项优化,但一些其他的虚拟机(如Excelsior JET)使用了该优化。
JVM的GC模块会回收堆中不再使用的对象,但如下回收动作
标记筛选出可回收对象
回收和整理内存
都需耗费大量资源。
若确定一个对象不会逃逸出线程,那让该对象在栈上分配内存就是个不错主意,对象所占用内存空间就可随栈帧出栈而销毁。
在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占比例很大,若能使用栈上分配,则大量对象就会随方法结束而自动销毁,GC系统压力会下降很多。
栈上分配可支持方法逃逸,但不能支持线程逃逸。
2.2 标量替换(Scalar Replacement)
2.2.1 标量
若一个数据已经无法再分解成更小数据,JVM中的原始数据类型(如 int、long 等数值类型及 reference 类型)都不能再进一步分解,这些数据即为标量。
2.2.2 聚合量
若一个数据可继续分解,则称为聚合量(Aggregate),比如 Java 对象就是聚合量。
2.2.3 标量替换
把一个Java对象拆散,根据程序访问情况,将其用到的成员变量恢复为原始类型来访问。
假如逃逸分析能证明一个对象不会被方法外部访问,并且该对象可被分解,那么程序真正执行时将可能不去创建该对象,而改为直接创建它的若干个被这方法使用的成员变量。
将对象拆分后:
可让对象的成员变量在栈上 (栈上存储的数据,很大概率会被JVM分配至物理机器的高速寄存器中存储)分配和读写
为后续进步优化创建条件
2.2.4 适用场景
标量替换可视为栈上分配一种特例,实现更简单(不用考虑对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。
2.3 同步消除(Synchronization Elimination)
线程同步是个相对耗时的过程,若逃逸分析能确定一个变量不会逃逸出线程,即不会被其他线程访问,则该变量的读写肯定不会有线程竞争, 也可安全消除对该变量实施的同步措施。
逃逸分析的论文在1999年就已发表,但到JDK 6,HotSpot才开始初步支持逃逸分析,至今该也尚未成熟,主要因为逃逸分析的计算成本高到无法保证带来的性能收益会高于它的消耗。要百分百准确判断一个对象是否会逃逸,需进行一系列复杂数据流敏感的过程间分析,才能确定程序各个分支执行时对此对象的影响。过程间分析这种大压力的分析算法正是即时编译的弱项。试想,若逃逸分析完毕后发现几乎找不到几个不逃逸的对象, 那这些运行期耗用的时间就白费了,所以目前JVM只能采用不那么准确,但时间压力相对较小的算法来完成分析。
C和C++原生支持栈上分配(不使用new即可),灵活运用栈内存方面,Java的确是弱势群体。
在现在仍处于实验阶段的Valhalla项目,设计了新的inline关键字用于定义Java的内联类型, 对标C#的值类型。有了该标识与约束,以后逃逸分析做起来就会简单很多。
3 代码实战验证
3.1 全无优化的代码
public int test(int x) {
int xx = x + 2;
Point p = new Point(xx, 42);
return p.getX();
}
3.2 优化step1:内联构造器和getX()方法
public int test(int x) {
int xx = x + 2;
// 在堆中分配P对象
Point p = point_memory_alloc();
// Point构造器被内联后
p.x = xx;
p.y = 42;
// Point::getX()被内联后
return p.x;
}
优化step2:标量替换
逃逸分析后,发现在整个test()方法的范围内Point对象实例不会发生任何程度逃逸, 便可对它进行标量替换:把其内部的x和y直接置换出来,分解为test()方法内的局部变量,从而避免了Point对象实例的创建
public int test(int x) {
int xx = x + 2;
int px = xx;
int py = 42
return px;
}
step3:无效代码消除
数据流分析,发现py的值其实对方法不会造成任何影响,那就可以放心地去做无效代码消除得到最终优化结果,如下所示:
public int test(int x) {
return x + 2;
}
观察测试结果,实施逃逸分析后的程序在MicroBenchmarks中往往能得到不错的成绩,但在实际应用程序中,尤其是大型程序中反而发现实施逃逸分析可能出现效果不稳定,或分析过程耗时但却无法有效判别出非逃逸对象而导致性能(即时编译的收益)下降,所以曾经在很长的一段时间,即使是服务端编译器,也默认不开启逃逸分析(从JDK 6 Update 23开始,服务端编译器中开始才默认开启逃逸分析。),甚至在某些版本(如JDK 6 Update 18)中还曾完全禁止这项优化,一直到JDK 7时这项优化才成为服务端编译器默认开启的选项。
若有需要或确认对程序有益,可使用参数:
-XX:+DoEscapeAnalysis 手动开启逃逸分析
开启后可通过参数:
-XX:+PrintEscapeAnalysis 查看分析结果
有逃逸分析支持后,用户可使用如下参数:
-XX:+EliminateAllocations 开启标量替换
+XX:+EliminateLocks 开启同步消除
-XX:+PrintEliminateAllocations 查看标量的替换情况
让我们一起期待该JIT优化技术之逃逸分析的发展。
参考
《深入理解 Java 虚拟机》
往期推荐
目前交流群已有 800+人,旨在促进技术交流,可关注公众号添加笔者微信邀请进群
喜欢文章,点个“在看、点赞、分享”素质三连支持一下~