JVM 通过「逃逸分析」就能让对象在「栈上分配」?

原文地址:https://mp.weixin.qq.com/s/Pub_K7PSCNE82F-96y2v6g?scene=25#wechat_redirect

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

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

什么是逃逸分析?

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

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

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

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

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

逃逸分析原则

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

图片

1、全局逃逸(GlobalEscape)

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

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

2、参数逃逸(ArgEscape)

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

3、没有逃逸(NoEscape)

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

逃逸分析优化

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

1、锁消除

比如以下代码。

图片

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

图片

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

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

图片

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

图片

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

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

图片

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

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

java TestLockEliminate 一共耗费:720 ms

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

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

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

2、对象分配消除

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

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

图片

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

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

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

图片

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

图片

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

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

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

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

图片

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

图片

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

注意:

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

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

图片

mixed mode 代表混合模式。

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

3、标量替换

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

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

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

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

图片

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

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

图片

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

total += i;

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

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值