【JVM】深入解析 G1 的并发标记与三色标记
前言
📫作者简介:小明java问道之路,专注于研究计算机底层/Java/Liunx 内核,就职于大型金融公司后端高级工程师,擅长交易领域的高安全/可用/并发/性能的架构设计📫
🏆 InfoQ 签约作者、CSDN 专家博主/Java 领域优质创作者/CSDN 内容合伙人、阿里云专家/签约博主、华为云专家、51CTO 专家/TOP 红人 🏆
🔥如果此文还不错的话,还请👍关注、点赞、收藏三连支持👍一下博主~
本文导读
你了解 G1 垃圾回收器吗,有 G1 还需要其他的回收器?有了 G1 为什么还要 ZGC?不懂?一文带你搞懂 G1 垃圾回收器。
一、三色标记
在三色标记法之前有一个算法叫 Mark-And-Sweep(标记清除)。这个算法会设置一个标志位来记录对象是否被使用。最开始所有的标记位都是 0,如果发现对象是可达的就会置为 1,一步步下去就会呈现一个类似树状的结果。等标记的步骤完成后,会将未被标记的对象统一清理,再次把所有的标记位设置成 0 方便下次清理。
这个算法最大的问题是 GC 执行期间需要把整个程序完全暂停,不能异步进行 GC 操作。因为在不同阶段标记清扫法的标志位 0 和 1 有不同的含义,那么新增的对象无论标记为什么都有可能意外删除这个对象。对实时性要求高的系统来说,这种需要长时间挂起的标记清扫法是不可接受的。所以就需要一个算法来解决 GC 运行时程序长时间挂起的问题,那就三色标记法。
三色标记最大的好处是可以异步执行,从而可以以中断时间极少的代价或者完全没有中断来进行整个 GC。
三色标记法很简单。首先将对象用三种颜色表示,分别是白色、灰色和黑色。黑色:根对象,或者该对象与它的子对象都被扫描过。灰色:对本身被扫描,但是还没扫描完该对象的子对象。白色:未被扫描对象,如果扫描完所有对象之后,最终为白色的为不可达对象,既垃圾对象。
二、三色标记的问题-GC 并发情况下的漏标问题
三、CMS 中的解决方案
Incremental Update 算法:
当一个白色对象被一个黑色对象引用,将黑色对象重新标记为灰色,让垃圾回收器重新扫描
四、G1 中的解决方案
SATB(snapshot-at-the-beginning) 保证在并发标记的正确性
刚开始做一个快照,当 B 和 C 消失的时候要把这个引用推到 GC 的堆栈,保证 C 还能被 GC 扫描到,最重要的是要把这个引用推到 GC 的堆栈,是灰色对象指向白色的引用,如果一旦某一个引用消失掉了,我会把它放到栈(GC 方法运行时数据也是来自栈中),我其实还是能找到它的,我下回直接扫描他就行了,那样白色就不会漏标。对应 G1 的垃圾回收过程中的:最终标记( Final Marking)对用户线程做另一个短暂的暂停,用于处理并发阶段结后仍遗留下来的最后那少量的 SATB 记录(漏标对象)。
五、对比
SATB 算法是关注引用的删除。(B->C 的引用)Incremental Update 算法关注引用的增加。(A->C 的引用)G1 如果使用 Incremental Update 算法,因为变成灰色的成员还要重新扫,重新再来一遍,效率太低了。所以 G1 在处理并发标记的过程比 CMS 效率要高,这个主要是解决漏标的算法决定的。
六、G1 中的技术细节
1、跨代引用
堆空间通常被划分为新生代和老年代。由于新生代的垃圾收集通常很频繁,如果老年代对象引用了新生代的对象,那么回收新生代的话,需要跟踪从老年代到新生代的所有引用,所以要避免每次 YGC 时扫描整个老年代,减少开销。
2、RSet (记忆集)-G1 底层数据结构
记录了其他 Region 中的对象到本 Region 的引用,RSet 的价值在于使得垃圾收集器不需要扫描整个堆,找到谁引用了当前分区中的对象,只需要扫描 RSet 即可。RSet 本身就是一个 Hash 表,如果是在 G1 的话,则是在一个 Region 区里面。
RSet 是一个空间换时间的数据结构。卡表(Card Table)的数据结构,用来解决跨代引用的问题。RSet 的功能与此类似,它的全称是 Remembered Set,用于记录和维护 Region 之间的对象引用关系。
但 RSet 与 Card Table 有些不同的地方。Card Table 是一种 points-out(我引用了谁的对象)的结构。而 RSet 记录了其他 Region 中的对象引用本 Region 中对象的关系,属于 points-into 结构(谁引用了我的对象),有点倒排索引的味道。
你可以把 RSet 理解成一个 Hash,key 是引用的 Region 地址,value 是引用它的对象的卡页集合。
有了这个数据结构,在回收某个 Region 的时候,就不必对整个堆内存的对象进行扫描了。它使得部分收集成为了可能。
对于年轻代的 Region,它的 RSet 只保存了来自老年代的引用,这是因为年轻代的回收是针对所有年轻代 Region 的,没必要画蛇添足。所以说年轻代 Region 的 RSet 有可能是空的。
而对于老年代的 Region 来说,它的 RSet 也只会保存老年代对它的引用。这是因为老年代回收之前,会先对年轻代进行回收。这时,Eden 区变空了,而在回收过程中会扫描 Survivor 分区,所以也没必要保存来自年轻代的引用。
RSet 通常会占用很大的空间,大约 5% 或者更高。不仅仅是空间方面,很多计算开销也是比较大的。事实上,为了维护 RSet,程序运行的过程中,写入某个字段就会产生一个 post-write barrier 。为了减少这个开销,将内容放入 RSet 的过程是异步的,而且经过了很多的优化:Write Barrier 把脏卡信息存放到本地缓冲区(local buffer),有专门的 GC 线程负责收集,并将相关信息传给被引用 Region 的 RSet。参数 -XX:G1ConcRefinementThreads 或者 -XX:ParallelGCThreads 可以控制这个异步的过程。如果并发优化线程跟不上缓冲区的速度,就会在用户进程上完成。
3、CardTable
由于做新生代 GC 时,需要扫描整个 OLD 区,效率非常低,所以 JVM 设计了 CardTable,如果一个 OLD 区 CardTable 中有对象指向 Y 区, 就将它设为 Dirty(标志位 1), 下次扫描时,只需要扫描 CARDTABLE 上是 Dirty 的内存区域即可。字节数组 CARDTABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。 一般来说,卡页大小都是以 2 的 N 次幂的字节数,假设使用的卡页是 2 的 10 次幂,即 1K,内存区域的起始地址是 0x0000 的话,数组 CARD_TABLE 的第 0、1、2 号元素,分别对应了地址范围为 0x0000~0x03FF、0x0400 ~ 0x07FF、0x0800~0x011FF 的卡页内存。
4、安全点与安全区域
4.1 安全点
用户线程暂停,GC 线程要开始工作,但是要确保用户线程暂停的这行字节码指令是不会导致引用关系的变化。所以 JVM 会在字节码指令中,选一些指令,作为“安全点”,比如方法调用、循环跳转、异常跳转等,一般是这些指令才会产生安全点。为什么它叫安全点,是这样的,GC 时要暂停业务线程,并不是抢占式中断(立马把业务线程中断)而是主动是中断。主动式中断是设置一个标志,这个标志是中断标志,各业务线程在运行过程中会不停的主动去轮询这个标志,一旦发现中断标志为 True,就会在自己最近的“安全点”上主动中断挂起。
4.2 安全区域
为什么需要安全区域?要是业务线程都不执行(业务线程处于 Sleep 或者是 Blocked 状态),那么程序就没办法进入安全点,对于这种情况,就必须引入安全区域。安全区域是指能够确保在某一段代码片段之中, 引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区城看作被扩展拉伸了的安全点。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,这段时间里 JVM 要发起 GC 就不必去管这个线程了。当线程要离开安全区域时,它要 JVM 是否已经完成了(根节点枚举,或者其他 GC 中需要暂停用户线程的阶段)1、如果完成了,那线程就当作没事发生过,继续执行。2、否则它就必须一直等待, 直到收到可以离开安全区域的信号为止
总结
本文讲述 G1 垃圾回收器的回收过程,并着重看了一下底层的数据结构 RSet。相对于 CMS,G1 有了更可靠的驾驭度。而且有 RSet 和 SATB 等算法的支撑,Remark 阶段更加高效。G1 最重要的概念,其实就是 Region。它采用分而治之,部分收集的思想,尽力达到我们给它设定的停顿目标。G1 的垃圾回收过程分为三种,其中,并发标记阶段,为更加复杂的 Mixed GC 阶段做足了准备。
版权声明: 本文为 InfoQ 作者【小明Java问道之路】的原创文章。
原文链接:【http://xie.infoq.cn/article/8a1bffb63da2881a85826fc8e】。文章转载请联系作者。
评论