经常会有面试官会问一个问题: Java 中 的对象都是在"堆"中创建吗? 然后跟求职者大谈特谈"逃逸分析",说通过"逃逸分析",JVM会将实例对象分配在" 栈" 上。 其实这种说法是并不是很严谨,最起码目前在HotSpot中并没有在栈中存储对象的实现代码!
什么是逃逸分析?
首先逃逸分析是一种算法,这套算法在Java即时编译器(JIT)编译Java源代码时使用。通过逃逸分析算法可以分析出某一个方法中的某个对象是否会被其它方法或者线程访问到。如果分析结果显示某对象并不会被其它线程访问,则有可能在编译期间其做一些深层次的优化,具体有哪些优化稍后讲解。
执行java程序时,可以通过如下参数开启或者关闭"逃逸分析"
开启逃逸分析:-XX: + DoEscapeAnalysis
-
DoEscapeAnalysis
逃逸分析原则
在HotSpot源码中的 escape.hpp 中定义了对象进行逃逸分析后的几种状态:
路径:src/share/vm/opto/escape.hpp
即一个对象的作用范围逃出了当前方法或者当前线程,有以下几种场景:
对象是一个静态变量
对象作为当前方法的返回值
如果复写了类的finalize方法,则此类的实例对象都是全局逃逸状态( 因此 为了提高性能,除非万不得已,不要轻易复写finalize方法 )
即一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会再被其它方法或者线程访问。
即方法中的对象没有发生逃逸,这种对象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源代码中的一大坨在真正执行时,只有简单的一行操作。因此 sumPrimitive
和 sumMutableWrapper 两个方法的执行效率基本一致。
最后说一句
原创不易,如果觉得写得不错就点个"在看"吧,或者转发更佳
也可以加我微信,探讨更多技术问题
或者关注公众号,查看更多文章