JVM栈上分配对象内存与逃逸分析原理分析(Escape Analysis)

2022-11-30 15:31:15 浏览数 (1)

1 逃逸分析

JVM中较前沿的优化技术,它与类型继承关系分析一样,并非直接优化代码,而是为其他优化措施提供依据的分析技术。

1.1 基本原理

分析对象动态作用域,当一个对象在方法里面被定义后,它可能

  • 被外部方法所引用 例如作为调用参数传递给其他方法,称为方法逃逸
  • 被外部线程访问 譬如赋值给可以在其他线程中访问的实例变量,称为线程逃逸

从不逃逸 =》方法逃逸 =》线程逃逸,称为对象由低到高的不同逃逸程度。

如果能证明一个对象不会逃逸到方法或线程外(即别的方法或线程无法通过任何途径访问到该对象),或逃逸程度较低(只逃逸出方法而不逃逸出线程),则可能为这个对象实例采取不同程度的优化,如:

2 栈上分配(Stack Allocations)

由于复杂度等原因,HotSpot中目前暂时还没有做这项优化,但一些其他的虚拟机(如Excelsior JET)使用了该优化。

JVM中,Java堆上分配创建对象的内存空间是常识,Java堆中的对象对各线程共享可见,只要持有该对象的引用,就可访问到堆中存储的对象数据。 虚拟机的GC子系统会回收堆中不再使用的对象,但回收动作无论是标记筛选出可回收对象,还是回收和整理内存,都需耗费大量资源。 如果确定一个对象不会逃逸出线程,那让该对象在栈上分配内存是个不错主意,对象所占用内存空间就可随栈帧出栈而销毁。 在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例很大,如果能使用栈上分配,那大量对象就会随方法结束而自动销毁,GC子系统压力会下降很多。栈上分配可支持方法逃逸,但不能支持线程逃逸。

3 标量替换(Scalar Replacement)

若一个数据已经无法再分解成更小数据来表示,JVM中基础数据类型都不能再进一步分解,这些数据可被称为标量。 相对的,如果一个数据可以继续分解,那它就被称为聚合量(Aggregate),Java 中的对象就是典型的聚合量。如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,该过程就称为标量替换。 假如逃逸分析能够证明一个对象不会被方法外部访问,并且该对象可被分解,那么程序真正执行时将可能不去创建该对象,而改为直接创建它的若干个被这方法使用的成员变量代替。 将对象拆分后,除可让对象的成员变量在栈上 (栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写外,还可为后续进步优化创建条件。 标量替换可视作栈上分配一种特例,实现更简单(不用考虑对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。

4 同步消除(Synchronization Elimination)

线程同步本身是一个相对耗时过程,如果逃逸分析能确定一个变量不会逃逸出线程,无法被其他线程访问,那么该变量读写肯定不会有竞争, 对该变量实施的同步措施也可安全消除。 关于逃逸分析的研究论文早在1999年就已经发表,但到JDK 6,HotSpot才开始支持初步逃逸分析,到现在这优化技术尚未足够成熟。 不成熟的原因主要是逃逸分析的计算成本非常高,甚至不能保证逃逸分析带来的性能收益会高于它的消耗。要百分之百准确地判断一个对象是否会逃逸,需要进行一系列复杂的数据流敏感的过程间分析,才能确定程序各个分支执行时对此对象的影响。 前面介绍即时编译、提前编译优劣势时提到了过程间分析这种大压力的分析算法正是即时编译的弱项。可以试想一下,如果逃逸分析完毕后发现几乎找不到几个不逃逸的对象, 那这些运行期耗用的时间就白白浪费了,所以目前虚拟机只能采用不那么准确,但时间压力相对较小的算法来完成分析。 C和C 原生支持栈上分配(不使用new即可),而C#也支持值类型,可以自然做到标量替换(但并不会对引用类型做这种优化)。 在灵活运用栈内存方面,确实是Java的弱项。 在现在仍处于实验阶段的Valhalla项目里,设计了新的inline关键字用于定义Java的内联类型, 目的是实现与C#中值类型相对标的功能。有了这个标识与约束,以后逃逸分析做起来就会简单很多。

下面通过一系列Java伪代码的变化过程来模拟逃逸分析是如何工作的,向读者展示逃逸分析能够实现的效果。 初始代码如下所示:

代码语言:javascript复制
// 完全未优化代码 
public int test(int x) { 
	int xx = x   2; 
	Point p = new Point(xx, 42); 
	return p.getX(); 
}

此处省略了Point类的代码。第一步,将Point的构造函数和getX()方法进行内联优化:

代码语言:javascript复制
 // 步骤1:构造函数内联后的样子 
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对象实例被实际创建,优化后的结果如下所示:

代码语言:javascript复制
 // 步骤2:标量替换后的样子 
 public int test(int x) { 
 	int xx = x   2; 
 	int px = xx; 
 	int py = 42 
 	return px; 
 } 

第三步,通过数据流分析,发现py的值其实对方法不会造成任何影响,那就可以放心地去做无效 代码消除得到最终优化结果,如下所示:

代码语言:javascript复制
// 步骤3:做无效代码消除后的样子 
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查看标量的替换情况。 尽管目前逃逸分析技术仍在发展之中,未完全成熟,但它是即时编译器优化技术的一个重要前进 方向,在日后的Java虚拟机中,逃逸分析技术肯定会支撑起一系列更实用、有效的优化技术。

参考

  • 《深入理解 Java 虚拟机》

0 人点赞