不要让你的Java对象"逃逸"了

createh52个月前 (02-01)技术教程10

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

标量替换

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


首先要明白标量和聚合量,基础类型和对象的引用可以理解为标量,它们不能被进一步分解。而能被进一步分解的量就是聚合量,对象就是聚合量,它可以被进一步分解成标量,将其成员变量分解为分散的变量,这就叫做标量替换

这样,如果一个对象没有发生逃逸,那压根就不需要在"堆"中创建它,只会在栈或者寄存器上创建一些能够映射这个对象标量即可,节省了内存空间,也提升了应用程序性能。


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

乍看一下,sumPrimitive方法比 sumMutableWrapper 方法简单的多,那执行效率也肯定快许多吧,但是结果却是两个方法的执行效率相差无几。这是为什么呢?

sumMutableWrapper 方法中,MutableWrapper是不可逃逸对象,也就是说没有必要再"堆"中创建真正的MutableWrapper对象,Java即时编译器会使用标量替换对其进行优化,优化结果为下:

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

total += i;

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

原文链接:
https://mp.weixin.qq.com/s/Pub_K7PSCNE82F-96y2v6g

相关文章

java小知识-ShutdownHook(优雅关闭)

作者:京东物流 崔冬冬一、先提出一个问题我们如果在JVM退出的时候做一些事情,比如关闭远程链接,怎么实现呢?二、ShutdownHook简介java里有个方法Runtime.getRuntime#ad...

还不理解 Error 和 Exception 吗,看这篇就够了

在 Java 中的基本理念是 结构不佳的代码不能运行,发现错误的理想时期是在编译期间,因为你不用运行程序,只是凭借着对 Java 基本理念的理解就能发现问题。但是编译期并不能找出所有的问题,有一些 N...

我的世界:java vs 基岩的11个不同之处!这是,天堂传送门?

《我的世界》基岩版 vs Java版向来不是完全统一的两个版本,最为经典的差异想必也就是为人所熟知的“船”的合成配方了。然而我们今天就来聊一些更为细节的不同之处!1、基岩版的船无法承载掉落物,JAVA...

经验分享|程序员成功转行IC,我终于不用再吃“青春饭”

近期后台收到了一位同学的私信,转行IC后他的薪资涨了很多,这位同学之前是程序员,经过不断努力成功转行到IC后端。我是西安一所普通的一本毕业,学的是自动化专业,大学跟大多数人一样过得浑浑噩噩,对自己的职...

如何优雅地关闭线程池?(关闭线程的方法)

在 Java的线程池管理中,shutdown()和shutdownNow()是用于关闭线程池的两种方法,尽管都是为了关闭线程池,但它们存在显著差异。这篇文章,我们将详细阐述它们的工作原理。shutdo...

实战!如何在退出登录时借助外力使JWT令牌失效?

大家好,我是不才陈某~今天这篇文章介绍一下如何在修改密码、修改权限、注销等场景下使JWT失效。文章的目录如下:解决方案JWT最大的一个优势在于它是无状态的,自身包含了认证鉴权所需要的所有信息,服务器端...