9

JVM 通过「逃逸分析」就能让对象在「栈上分配」?没那么简单!

 3 years ago
source link: https://mp.weixin.qq.com/s?__biz=MzIxNjc0ODExMA%3D%3D&%3Bmid=2247487013&%3Bidx=1&%3Bsn=940f2daf94aeb9f865f2f61d53479beb
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
Rj2y6fF.jpg!mobileIryeeqn.jpg!mobile

本文转载自公众号 星哥笔记

作者:Danny姜

校对:承香墨影

经常会有面试官会问一个问题: Java 中的对象都是在"堆"中创建吗?

然后跟求职者大谈特谈「逃逸分析」,说通过「逃逸分析」,JVM 会将实例对象分配在「栈」上。其实这种说法,是并不是很严谨,最起码目前在 HotSpot 中,并没有在栈中存储对象的实现代码!

什么是逃逸分析?

首先逃逸分析是一种算法,这套算法在 Java 即时编译器( JIT ),编译 Java 源代码时使用。通过逃逸分析算法,可以分析出某一个方法中的某个对象,是否会被其它方法或者线程访问到。

如果分析结果显示,某对象并不会被其它线程访问,则有可能在编译期间,对其做一些深层次的优化,具体有哪些优化稍后讲解。

执行 java 程序时,可以通过如下参数开启或者关闭"逃逸分析"。

开启逃逸分析:-XX:+DoEscapeAnalysis

关闭逃逸分析:-XX:-DoEscapeAnalysis

逃逸分析原则

在 HotSpot 源码中的 escape.hpp 中定义了对象进行逃逸分析后的几种状态。( 路径:src/share/vm/opto/escape.hpp

zuqU7ve.png!mobile

1、全局逃逸(GlobalEscape)

即一个对象的作用范围,逃出了当前方法或者当前线程,有以下几种场景:

  • 对象是一个静态变量;

  • 对象作为当前方法的返回值;

  • 如果复写了类的 finalize 方法,则此类的实例对象都是全局逃逸状态( 因此为了提高性能,除非万不得已,不要轻易复写 finalize 方法 );

2、参数逃逸(ArgEscape)

即一个对象,被作为方法参数传递,或者被参数引用,但在调用过程中,不会再被其它方法或者线程访问。

3、没有逃逸(NoEscape)

即方法中的对象,没有发生逃逸,这种对象会被 Java 即时编译器进一步的优化。

逃逸分析优化

经过「逃逸分析」之后,如果一个对象的逃逸状态是 GlobalEscape 或者 ArgEscape,则此对象必须被分配在「堆」内存中,但是对于 NoEscape 状态的对象,则不一定,具体会有以下几种优化情况。

1、锁消除

比如以下代码。

fyAramz.png!mobile

lockElimination() 方法中,对象 a 永远不会被其它方法或者线程访问到,因此 a 是非逃逸对象,这就导致 synchronized(a) 没有任何意义,因为在任何线程中, a 都是不同的锁对象。所以 JVM 会对上述代码进行优化,删除同步相关代码,以下:

I3Mfimq.png!mobile

对于锁消除,还有一个比较经典的使用场景: StringBuffer

StringBuffer 是一个使用同步方法的线程安全的类,可以用来高效地拼接不可变的字符串对象。StringBuffer 内部对所有 append() 方法都进行了同步操作,如下所示:

z67fm2Z.png!mobile

但是在平时开发中,有很多场景其实是不需要这层线程安全保障的,因此在 Java 5 中又引入了一个非同步的 StringBuilder 类来作为它的备选,StringBuilder 中的 append() 方法并没有使用 synchronized 标识,如下所示:

UzAfuiN.png!mobile

调用 StringBuffer 的 append() 方法的线程,必须得获取到这个对象的内部锁( 也叫监视器锁 )才能进入到方法内部,在退出方法前也必须要释放掉这个锁。而 StringBuilder 就不需要进行这个操作,因此它的执行性能比 StringBuffer 的要高--至少乍看上去是这样的。

不过在 HotSpot 虚拟机引入了「逃逸分析」之后,在调用 StringBuffer 对象的同步方法时,就能够自动地把锁消除掉了。从而提高 StringBuffer 的性能,比如以下代码:

fIjM7vq.png!mobile

getString() 方法中的 StringBuffer 是方法内部的局部变量,并且并没有被当做方法返回值返回给调用者,因此 StringBuffer 是一个"非逃逸( NoEscape )"对象。

执行上述代码,结果如下:

java TestLockEliminate 一共耗费:720 ms

我们可以通过 -XX:-EliminateLocks 参数关闭锁消除优化,重新执行上述代码,结果如下:

java -XX:-EliminateLocks TestLockEliminate 一共耗费: 1043 ms

可以看出,关闭锁消除后性能会降低,耗时更多。

2、对象分配消除

除了锁消除,JVM 还会对无逃逸( NoEscape )对象进行 对象分配消除 优化。对象分配消除是指将本该在「堆」中分配的对象,转化为由「栈」中分配。乍听一下,很不可思议,但是我们可以通过一个案例来验证一下。

比如以下代码,在一个 1 千万次的循环中,分别创建 EscapeTest 对象 t1 和 t2。

EJJf63Y.png!mobile

使用如下命令执行上述代码

java -Xms2g -Xmx2g -XX:+PrintGCDetails -XX:-DoEscapeAnalysis EscapeTest

通过参数 -XX:-DoEscapeAnalysis 关闭「逃逸分析」,然后代码会在 System.in.read() 处停住,此时使用 jps 和 jmap 命令查看内存中 EscapeTest 对象的详细情况,如下:

jIbui2I.png!mobile

可以看出,此时堆内存中有 2 千万个 EscapeTest 的实例对象( t1 和 t2 各 1 千万个 ),GC 日志如下:

NZZRv26.png!mobile

没有发生 GC 回收事件,但是 eden 区已经占用 96%,所有的 EscapeTest 对象都在"堆"中分配。

如果我们将执行命令修改为如下:

java -Xms2g -Xmx2g -XX:+PrintGCDetails -XX:+DoEscapeAnalysis EscapeTest

开启「逃逸分析」,并重新查看 EscapeTest 对象情况如下:

eeiaiqU.png!mobile

可以看出,此时堆内存中只有 30 万个左右,并且 GC 日志如下:

77rY3uF.png!mobile

没有发生 GC 回收时间,EscapeTest 只占用 eden 区的 8%,说明并没有在堆中创建 EscapeTest 对象,取而代之的是分配在「栈」中。

注意:

有的读者可能会有疑问:开启了「逃逸分析」,NoEscape 状态的对象,不是会在「栈」中分配吗?

为什么这里还是会有 30 多万个对象在「堆」中分配?这是因为我使用的 JDK 是混合模式,通过 java -version 查看 java 的版本,结果如下:

U7zqe2R.png!mobile

mixed mode 代表混合模式。

在 Hotspot 中采用的是解释器和编译器并行架构,所谓的混合模式,就是解释器和编译器搭配使用,当程序启动初期,采用解释器执行( 同时会记录相关的数据,比如函数的调用次数,循环语句执行次数 ),节省编译的时间。在使用解释器执行期间,记录的函数运行的数据,通过这些数据发现某些代码是热点代码,采用编译器对热点代码进行编译,以及优化( 逃逸分析就是其中一种优化技术 )。

3、标量替换

上文中,我提到当「逃逸分析」后,对象状态为 NoEscape 时会在「栈」中进行分配。但是实际上,这种说法并不是完全准确的,「栈」中直接分配对象难度太大,需要修改 JVM 中大量堆优先分配的代码,因此在 HotSpot 中并没有真正的实现"栈"中分配对象的功能,取而代之的是一个叫做「标量替换」的折中办法。

首先要明白标量和聚合量,基础类型和对象的引用可以理解为标量,它们不能被进一步分解。而能被进一步分解的量,就是聚合量。

对象就是聚合量,它可以被进一步分解成标量,将其成员变量分解为分散的变量,这就叫做「标量替换」。这样如果一个对象没有发生逃逸,那压根就不需要在「堆」中创建它,只会在栈或者寄存器上创建一些能够映射这个对象标量即可,节省了内存空间,也提升了应用程序性能。

比如以下两个计算和的方法:

y6fyquu.png!mobile

乍看一下, sumPrimitive() 方法比   sumMutableWrapper() 方法简单的多,那执行效率也肯定快许多吧?

但是结果却是两个方法的执行效率相差无几。这是为什么呢?在 sumMutableWrapper() 方法中,MutableWrapper 是不可逃逸对象,也就是说没有必要在「堆」中创建真正的 MutableWrapper 对象,Java 即时编译器会使用标量替换对其进行优化,优化结果为下:

Rv2UNrj.png!mobile

仔细查看,上述优化够的代码中的 value 也是一个中间变量,通过内联之后,会被优化为如下:

total += i;

也就是说,java 源代码中的一大坨在真正执行时,只有简单的一行操作。因此 sumPrimitive 和  sumMutableWrapper() 两个方法的执行效率基本一致。

本文对你有帮助吗? 留言、转发、点好看 是最大的支持,谢谢!热文推荐:

nUv6NbU.jpg!mobile

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK