1 前言
前一章节介绍了常见的对象存活判定算法和垃圾收集算法,里面提到了很多专有名词,这一章节主要就这些专有名词,加上自己的理解进行详细介绍。
2 三色标记(Tri-color Marking)
在三色标记法
之前有一个算法叫Mark-And-Sweep
(标记清除)。这个算法会设置一个标志位
来记录对象是否被使用。最开始所有的标志位都是 0
,如果发现对象是可达的
就会置为 1
,一步步下去就会呈现一个类似树状的结果。等标记的步骤完成后,会将未被标记的对象统一清理,再次把所有的标志位设置成 0
方便下次清理。
这个算法最大的问题
是 GC 执行期间需要把整个程序完全暂停
,不能异步进行 GC 操作。因为在不同阶段标记清除法的标志位 0
和 1
有不同的含义, 那么新增的对象无论标记为什么都有可能意外删除这个对象。对实时性要求高的系统来说,这种需要长时间挂起的标记清除法是不可接受的。所以就需要一个算法来解决 GC 运行时程序长时间挂起的问题
。
三色标记最大的好处
是可以异步执行
,从而可以以中断时间极少的代价
或者完全没有中断
来进行整个 GC。
2.1 三色
将对象按照“是否被访问过”这个条件用三种颜色标记,分别是白色
、灰色
和黑色
。
- 黑色:表示
对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过
。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
- 灰色:表示
对象已经被垃圾收集器访问过
,但这个对象上至少存在一个引用
还没有被扫描过。 - 白色:表示
对象尚未被垃圾收集器访问过
。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达,为垃圾对象
。
2.2 漏标问题
当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色
:
- 赋值器
插入
了一条或多条从黑色对象到白色对象的新引用
; - 赋值器
删除
了全部从灰色对象到该白色对象的直接或间接引用
。
比如GC 并发情况下
,两线程漏标发生的过程如下图:
当引用发生以下变化时:
A.c=C;
B.c=null;
线程1、2完成所有标记,但C对象是白色,被错误的回收。
因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个
即可。由此分别产生了两种解决方案
:
增量更新
(Incremental Update)原始快照
(Snapshot At The Beginning, SATB ) 。
2.2.1 CMS 中的解决方案
CMS采用了增量更新
方案,即
当一个白色对象被一个黑色对象引用,将黑色对象重新标记为灰色,让垃圾收集器重新扫描。
该方案破坏的是第一个条件
,当黑色对象插入新的
指向白色对象的引用关系时,就将这个新插入的引用记录
下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象
为根
(GC Roots),重新扫描一次。
2.2.2 G1/Shenandoah 中的解决方案
G1/Shenandoah则采用原始快照
方案,以上面三个图中的对象为例,即
刚开始做一个快照,当
B
和C
消失的时候要把这个引用推到 GC 的堆栈
,保证C
还能被 GC 扫描到,最重要的是要把这个引用
(是灰色对象指向白色对象的引用)推到 GC 的堆栈,如果一旦某一个引用消失掉了,我会把它放到栈(GC 方法运行时数据也是来自栈中),我其实还是能找到它的,下回直接扫描他就行了,那样白色就不会漏标。
原始快照要破坏的是第二个条件
,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录
下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象
为根
,重新扫描 一次。
对应 G1
的垃圾回收过程中的最终标记
( Final Marking),即
对用户线程做另一个短暂地
暂停
,用于处理并发阶段结后仍遗留下来的最后那少量的SATB 记录
(漏标对象)。
2.2.3 对比
SATB 算法
是关注引用的删除
。(B->C 的引用);Incremental Update 算法
关注引用的增加
。(A->C 的引用)
G1
如果使用 Incremental Update 算法
,因为变成灰色的成员还要重新扫,重新再来一遍,效率太低了。 所以 G1 在处理并发标记的过程比 CMS 效率要高,这个主要是解决漏标的算法
决定的。
2.3 写屏障(Write Barrier)
以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障
实现的。
写屏障
可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面
,在引用对象赋值时会产生一个环形(Around)通知
,供程序执行额外的动作,也就是说赋值的前后
都在写屏障的覆盖范畴内
。在赋值前的部分
的写屏障叫作写前屏障
(Pre-Write Barrier),在赋值后
的则叫作写后屏障
(Post-Write Barrier)。
HotSpot虚拟机
的许多收集器中都有使用到写屏障,但直至G1收集器
出现之前,其他收集器都只用到了写后屏障
。
在HotSpot虚拟机
里是通过写屏障
技术维护这些插入或删除的引用关系记录,而这些记录又是直接和一种叫做卡表
存储数据结构进行关联,使得垃圾收集器只需要重新扫描
这些已经标记在卡表中的对应指针,而不必扫描比如整个老年代
,就可实现避免漏标问题
发生。那卡表
为何物?
2.4 记忆集与卡表
记忆集
(RSet)是一种用于记录从非收集区域指向收集区域的指针集合
的抽象数据结构
。用以避免把整个老年代
(这里以分代收集为例,事实上所有涉及部分区域收集
(Partial GC)行为的垃圾收集器,典型的如G1、ZGC和Shenandoah收集器,都会面临跨代引用
的问题)加进GC Roots
扫描范围。
在垃圾收集的场景中,收集器只需要通过记忆集
判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针
的全部细节。
卡表
(Card Table)就是记忆集
的其中一种实现方式(可以按Java
语言中HashMap
与Map
的关系来类比理解),它将每个记录精确到一块内存区域
,该区域内有对象含有跨代指针。
卡表
最简单的形式可以只是一个字节数组
。
CARD_TABLE [this address >> 9] = 0;
字节数组
CARD_TABLE
的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page).一个卡页
的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针
,那就将对应卡表的数组元素的值标识为1
,称为这个元素变脏
(Dirty),没有则标识为0
。在垃圾收集发生时,只要筛选
出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针
,把它们加入GC Roots
中一并扫描。
在
G1
中是每一个Region
都需要一个RSet
的内存区域,导致有G1
的RSet
可能会占据整个堆容量的20%乃至更多
。
但是CMS
只需要一份
,所以就内存占用来说,G1
占用的内存需求更大,虽然G1
的优点很多,但是我们不推荐在堆空间比较小
的情况下使用G1
,尤其小于6个G
。
3 安全点与安全区域
3.1 安全点
用户线程暂停,GC线程
要开始工作,但是要确保用户线程暂停的这行字节码指令是不会导致引用关系的变化。所以 JVM 会在字节码指令
中,选一些指令,作为安全点
,比如方法调用
、循环跳转
、异常跳转
等,一般是这些指令才会产生安全点。
为什么它叫
安全点
?
GC 时要暂停业务线程,并不是抢占式中断(立马把业务线程中断)而是主动式中断
。主动式中断
是设置一个标志
,这个标志是中断标志
,各业务线程在运行过程中会不停的主动去轮询
这个标志,一旦发现中断标志为True
,就会在自己最近的安全点
上主动中断挂起
。
3.2 安全区域
为什么需要安全区域?
要是业务线程都不执行(比如:业务线程处于Sleep
或者是Blocked
状态),那么程序就没办法进入安全点
,对于这种情况,就必须引入安全区域
。
安全区域
是指能够确保在某一段代码片段
之中,引用关系不会发生变化,因此,在这个区域中任意地方
开始垃圾收集都是安全的
。
我们也可以把
安全区域
看作被扩展拉伸了的安全点
。
- 当用户线程执行到
安全区域
里面的代码时,首先会标识
自己已经进入了安全区域,这段时间里JVM要发起GC就不必去管这个线程了。 - 当线程要离开安全区域时,它要检查JVM是否已经完成了
根节点枚举
,或者其他GC中需要暂停用户线程的阶段:- 如果完成了,那线程就当作没事发生过,继续执行。
- 否则它就必须一直等待,直到收到可以离开安全区域的信号为止。