Java 引用逃逸那些事
- 2019 年 10 月 5 日
- 笔记
前言
为了对线上程序的性能进行优化分析, 最近在看广受推荐的《深入理解Java虚拟机》,整本书的内容不少, 目前只是根据自己所需的进行阅读, 在后续读完整本内容配合笔记再写篇博客来记录下.而现在阅读过程中,发现 引用逃逸 和 逃逸分析这个两个概念 并不太了解,还容易混淆,于是就写下这篇博客来帮助下认识 Java 中的 引用逃逸 和 逃逸分析.
正文
引用逃逸
Java 分配在堆上的对象都是靠引用来操作的,当对象在某个方法中都定义之后, 把它的引用作为其他方法的参数传递过去, 这样就叫做对象的引用逃逸,而如果原本对象在当前方法结束后就会被垃圾回收器标记和回收,但由于其引用被传递出去,当被一个长期存活的对象所持有时,那原来的对象的生命周期就变成跟这个长期存在于堆内存的对象一样, 对于这样一些临时对象没有做到即时的回收,就会造成JVM的内存占用, 严重情况更会是触发Full GC,从而影响程序性能.
臭名昭著的 this 引用逃逸
this 引用逃逸 在构造函数返回之前, 其他线程就通过this引用访问到了"未完成初始化"的对象, 而调用尚未构造完全的对象就会不可预知的问题, 因此this 引用逃逸引发的问题是线程安全问题.
主要发生场景是在构造函数启动线程,或者注册监听时发生,如下代码:
public class UnsafeThisEscape { private String id; public ThisEscape(String id) { new Thread(new EscapeRunnable()).start(); // ...其他代码 this.id = id; } private class EscapeRunnable implements Runnable { @Override public void run() { System.out.println("id: "+UnsafeThisEscape.this.id); // 在这里通过UnsafeThisEscape.this就可以引用UnsafeThisEscape对象, 但是此时UnsafeThisEscape对象可能还没有构造完成, 即发生了this引用的逃逸. } } }
如何避免 this 引用逃逸
想要避免 this 引用逃逸,那当然就是不要在构造器中执行其他线程与当前引用对象相关的操作, 构造器仅用来完成初始化操作, 在上面的场景中处理方式就是在构造函数中创建线程,但不启动它。在构造函数外面再启动,可以专门提供一个方法出来,调整后如下所示
public class SafeThisEscape { private Thread t; private String id; public ThisEscape(String id) { t = new Thread(new EscapeRunnable()); this.id = id; // ...其他代码 } public void init() { t.start(); } private class EscapeRunnable implements Runnable { @Override public void run() { System.out.println("id: "+UnsafeThisEscape.this.id); // 这里通过SafeThisEscape.this引用的对象,是已经构造完成的,保证了线程安全. } } }
还有一种逃逸场景就是针对构造器的监听注册情况,同理让监听事件的注册不在构造器中进行,而是提供一个单独方法完成.
总之,每当在一个构造器中需要做复杂逻辑处理和初始化,就应该考虑到这个问题, 应该尽量让构造器执行简单,必要的初始化操作,更多复杂处理放在单独一个方法执行.
逃逸分析
好了, 聊过 ''引用逃逸"问题之后我们再来看下 逃逸分析, 主要围绕下面问题来展开
- 什么是逃逸分析
- 逃逸分析是干什么的
- 为什么了解逃逸分析
什么是逃逸分析
逃逸分析 (Escape Analysis) 是Java虚拟机的分析技术, 通过动态分析代码的作用域,比如当一个对象在方法内定义之后,有没有被外部方法引用或者外部线程访问到.
逃逸分析干什么用
上面介绍逃逸分析技术主要是动态分析对象的作用域, 而JVM就使用利用它来为其他优化技术如栈上分配, 标量替换和同步消除等提供是否优化的判断依据, 当能证明一个对象不会逃逸到方法或者线程之外, 那么JVM就会对这个对象做优化.
标量替换优化
标量是指一个无法再分解成其他更小数据的数据,比如Java中基本数据类型和Reference类型.对应的就是聚合量,可以继续分解其数据,如Java的对象.而标量替换就是把Java对象访问导的成员变量作为局部变量直接使用,而不再创建对象.
标量替换可以通过JVM 参数 -XX:+EliminateAllocations
开启, 用-XX:+PrintEliminateAllocations
查看替换情况.
栈上分配优化
栈上分配技术就是让这个没有逃逸出方法的对象在栈上分配内存空间,并且随着栈帧出栈而销毁.当应用存在大量不会逃逸的局部对象时,如果使用栈上分配技术,那么大量对象就可以随着方法结束而销毁,从而减轻了垃圾收集器的工作. 但Hotspot并没有实现真正意义上的栈上分配,实际上是标量替换.
同步消除优化
在能确定一个变量不会被其他线程访问,即不存在读写竞争的情况下,JVM就会对这个变量消除掉原有对这个变量的同步操作,可以通过-XX:+EliminateLocks可以开启同步消除.
逃逸分析实例
接下用来实例代码来体会下逃逸分析在程序的作用,具体代码如下:
public class EscapeAnalysisLab { public static void main(String[] args) throws Exception { int sum = 0; int count = 1000000; for (int i = 0; i < count ; i++) { sum += getScore(i); } System.out.println(sum); System.in.read(); } private static int getScore(int score) { Card card = new Card(score); int i = card.getScore(); return i; } } class Card { private int score; public Card(int score) { this.score = score; } public int getScore() { return score; } }
由于在getScore
方法中Card对象不会存在逃逸, 就可以使用标量替换的优化手段直接在栈上分配成员变量 score,这样就不会生成大量User对象, 从而减少GC的压力.
对于上面的类,通过加虚拟机参数 -Xmx3G -Xmn2G -server -XX:-DoEscapeAnalysis
来启动Main方法, 其中 -XX:-DoEscapeAnalysis
表示关闭逃逸分析, (在JDK1.8之后都是默认启动的)

然后用 jps 指令查看该Java进程的PID, 并通过 jmap -histo pid 查看该程序堆上的对象分布情况,结果如下:

可以看出在关闭逃逸分析之后,Card对象在堆上分配的个数与遍历次数一致.
接下来移除JVM参数 -XX:-DoEscapeAnalysis
再次运行,同样通过jsp和jmap来获取到对象在堆的分配情况如下:

从结果可以发现启动逃逸分析之后,Card对象分配近乎原来的十分之一, 其他的对象都通过标量替换优化了,当然这个生成实例的数量多少还有JVM分层编译的优化有关,但并不是本文章的主题, 也足以证明逃逸分析的必要之处.
结语
本文主要学习Java的引用逃逸和逃逸分析技术,通过了解引用逃逸主要来防止出现特殊情况的线程安全问题,而逃逸分析技术则是JVM层面的优化编译技术,为了提现程序性能.虽然两者没有紧密的联系,但通过一起捆绑式的学习,也可以避免将这个两个概念混淆,深刻认识这个概念各自的特点.
参考资料
- this 引用逃逸
- Java并发编程之this逃逸问题
- 《深入理解Java虚拟机》
- 浅谈HotSpot逃逸分析