最近回顾 JVM safe point 与 safe region 又有一些新的感悟与收获,特别写篇文章总结一下。希望对各位的学习有所帮助。废话不多说我们开始正文
根节点枚举(Root set enumeration)
说到 safe point 我们必须要先从根节点枚举开始,而说到根节点枚举我们又必须从发生gc时如何判断对象是否“存活”开始。目前主流的算法有两种,分别为引用计数算法和可达性分析算法。而 java 则采用可达性分析算法来判断对象是否存活。
可达性分析
可达性分析这个算法的基本思路就是通过一个被称为”GC Roots“的根对象作为起始结果集,从这些根节点向下搜索,这整个过程被称为引用链,如果某个对象与这些“GC Roots”都没有任何引用链,则被认为对象不可达(“被视为已死亡”)。
那什么对象被可以作为“GC Root”呢?
- 虚拟机栈中引用的对象
- 方法区中引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
- Java虚拟机内部的引用(类型对应的Class对象,常驻的异常对象等等)
- 所有被synchronized持有的对象
- 反映 Java 虚拟机内部情况的 JMXBean、JVMTI中注册的回调、本地代码缓存等等
而一套完整的“GC Roots”也必须考虑分代回收和局部回收的问题。例如 Partial GC 针对堆中某块区域的发起垃圾回收时,也必须考虑此内存区域内的对象是否可能被其他区域引用? 所以此时也必须将这些关联区域的对象一起加入到“GC Roots”集合里,才能保证可达性分析的正确性。
OopMap
但是枚举出整个“GC Roots”是非常麻烦的,首先运行时数据本身就是动态的,在这个枚举的过程中必须保证其原子性。并且在今天 Java应用越来越大的情况下,单单一个方法区就有可能数百上千兆,里边的类、常量等等更是恒河沙数,如果要逐个找出这些 根节点实在是一个非常非常耗时的事情。
那 JVM 是如何解决的呢?
首先在进行根节点选举时,必须暂停全部的用户线程,我们把这个过程称为“Stop The Word”(下面简称STW)(注:但必须要说明,STW不一定是全局的,也可以是局部的,这和安全点的类型有关。此时说的必须暂停全部用户线程只是因为GC时必须使全部线程进入安全点(gc safe point),我们下文会详解。)
在HotSpot的解决方案中,是使用一组称为OopMap的数据结构来存放这些对象的引用(OopMap在类加载动作完成时生成)。也就是说当用户线程暂停下来之后,其实并不需要一个不漏的检查完所有的执行上下文和全局的引用位置。而是直接通过OopMap来获取栈上或寄存器里哪里有GC管理的指针(引用指针)。
安全点(safe point)
OopMap解决了一部分问题,但没有解决所有的问题。试想一下,对象中的引用关系并非一成不变,如果每次执行一条字节码指令都去生成一个OopMap那就必须消耗大量额外的存储空间。
为了解决这个问题HotSpot并没有让每条指令都生成OopMap,而是只在特定的位置生成OopMap,这个位置就被称为安全点(safe point)
HotSpot安全点的实现:
放置的安全点不能太多,当然也不能太少。放置安全点的位置一般是以“是否具有让程序长时间执行特征”为标准进行选定。不同虚拟机对于自己safe point的实现不一定一样。
解释器:每一段字节码的边界都可以作为一个safe point,因为对于解释器来说找到完整的执行状态实在是一件非常容易的事。
JIT:对于来说则是以每个方法临返回前,以及所有的非 counted loop(可数循环) 循环回跳之前,放置一个safe point。并且在每个safepoint生成一些“调试符号信息”,方便VM找到需要的运行状态。
注:每当线程确定要被暂停时,会设置一个 ready flag 标志作为响应让GC继续进行根节点枚举。这是一个握手协议。
而其他虚拟机,例如JRockit 则选择将安全点放置在方法的入口,和每个循环末尾回跳之前。
主动式中断:
我们搞明白了安全点被放置的位置之后,考虑另外一个问题:如何使虚拟机中的线程跑到安全点?
有两种方式可供选择,分别为:
抢先式中断(Preemptive Suspension),直接粗暴的暂停全部的用户线程,如果发现用户线程并不在安全点上,则继续恢复这条线程继续执行,让他一会再重新中断,直到跑到安全点上为止。(现在几乎没有虚拟机采用这种实现)。
主动式中断(Voluntary Suspension),设置一个标志位,用户线程执行过程中,不停的主动轮询这个标志,一旦发现中断标志为真,自己就在最近的安全点上主动中断挂起。这个轮询是否需要进入安全点的动作在每个安全点时发生,这个动作被称之为polling point,polling也有开销,这也是上文中我们提到的HotSpot并没每个字节码指令都放置一个safepoint的原因。
安全区域(safe region)
但如果某个线程并没有在执行时怎么办?例如某个线程正在Sleep或者Blocked,那这些线程是无法走到安全点的。为了解决这个问题我们引入安全区域(safe regoin)这个概念。
你可以把它理解为一个扩大的安全点(某个不会不会发生引用关系变化的区域)例如:线程被挂起,或者线程执行JNI函数等等。
每当线程进入这些安全区域时都会有一个 safe point 检查(上文中我们提到的那个握手协议对于安全区域来说它也是遵守的,换句话说它和线程进入安全点之前都会生成 ready flag),以便通知 JVM 此线程已经到达安全区域。并在退出时再次检查此次根节点枚举(或回收)是否已经完成,如果没有,他将继续在这个安全区域挂起。
关于安全区域前几天在网上看到一个非常有意思的代码:
执行结果:
是不是非常有意思~
简单解释一下,当线程1,线程2 是两个十分耗时的循环,并且根据HotSpot放置安全点的位置来说,上一段代码是典型的 counted loop(可数循环),也就是说每次循环回跳前是不会放置安全点的。而Thread.Sleep又触发了安全区域(为了定期清理),最后当Thread.sleep睡醒时(也就是从native方法返回时)又需要检查其他线程是否已经跑到安全点,如果没有跑到安全点,此时它就会继续挂起,这也是这段代码为什么会睡眠长达4秒多的原因了。
贴上原文链接有兴趣的小伙伴去原帖看看。
安全点的种类
本文虽然以“gc safe point”为例,但并不代表JVM中只有这种安全点,实际上安全点的种类是非常非常多的。另外一个比较常见的是偏向锁的安全点(biased safe point)它与 GC 安全点最大的区别就是,它并不是一个全局的安全点,也就是说并会引起全局的STW。
可以参考源码 vmOperations.hpp