三色标记、增量更新、原始快照、记忆集与卡表
- 三色标记
- 基本原来
- 错标、漏标
- 错标
- 漏标
- 增量更新
- 基本原理
- 写屏障
- 原始快照
- 基本原理
- 为什么G1使用原始快照而不用增量更新。
- 记忆集与卡表
三色标记
基本原来
三色标记是JVM的垃圾收集器用于标记对象是否存活的一种方法。
三色是指黑、白、灰三种颜色,用这三种颜色记录不同对象的状态。
- 黑:被标记为黑色的对象,表示该对象已经被GC线程扫描过,并且它所有引用的对象都被扫描过。
- 白:白色对象是所有对象一开始的颜色,表示该对象还未被GC线程扫描到。
- 灰:被标记为灰色的对象,表示该对象已经被GC线程扫描过,但是它还有引用的对象没有被扫描到。
GC垃圾搜集线程一旦完成遍历,堆内存中要么就是黑色的对象,要么就是白色的对象。如果是黑色的对象,表示它可以通过GC Roots的引用链访问到;如果是白色的对象,表示无法通过GC Roots的引用链访问到。
那么,此时GC垃圾收集线程就可以把白色对象视作垃圾对象把它们清理掉,释放它们占用的内存空间。
这样,看起来一切正常,但这是建立在GC进行垃圾收集时用户线程停顿的前提下,也就是有STW的前提下。如果是并发标记,也就是用户线程在GC进行对象图遍历标记存活对象的时候,用户线程继续运行,就会出现错标和漏标的问题。
错标、漏标
错标
错标就是多标,也就是这个对象是个垃圾对象,但是GC把它标记为存活对象。
这种现象一般是由于GC垃圾收集线程已经把一个对象遍历到并标记为灰色或者黑色,然后用户线程把到该对象的引用链断开了。
一个对象被标记为黑色或者灰色,那么该对象就会被认定为存活对象。然后用户线程才把到该对象的引用链断开,此时GC垃圾收集线程是不知道的,GC垃圾收集线程对于已经遍历过的对象,是不会再遍历的,因此该对象就会被当成存活对象被保留下来。但由于引用链已经断开,无法再访问到该对象,因此实际上该对象是个垃圾对象,是应该被回收的,因此这种现象就叫错标或者多标,也就是这个标记是多余的。
这种问题好解决,只要到下一次垃圾收集,还是会作为白色对象被回收的。
漏标
漏标是指本该被标记为存活的对象,但是GC没有标记,导致该对象被回收,此时用户线程访问该对象,就会发生空指针异常。
这种现象的发生必须满足两个条件:
- 一个灰色对象断开了对一个白色对象的引用。
- 一个黑色对象又建立对这个白色对象的应用。
在GC完成剩下的对象的遍历之后,GC就会把它作为白色对象把它回收掉。
如果用户线程通过这个空指针引用去访问这个被GC回收掉的对象,就会发生空指针异常,这显然是一个很严重的问题。
解决这个问题的方法就是增量更新和原始快照。
增量更新
基本原理
增量更新是CMS用于解决漏标的处理方式。也就是当黑色对象建立对白色对象的引用时,把该黑色对象标记为灰色。
然后在并发标记阶段结束之后,在重新标记阶段,从新从该灰色对象开始进行深度优先遍历。
这样,GC就可以把该白色对象标记为黑色,就不会被GC当成垃圾对象被回收。
但是把黑色对象修改为灰色的这个操作如何实现的呢,这肯定不是由GC垃圾收集线程完成的,因为它是不会在并发标记阶段重新扫描已经扫描过的对象的。
写屏障
实际上这个操作是通过写屏障实现,写屏障就是在指针赋值操作前后插入一段额外的逻辑。
增量更新是在黑色对象建立到白对象引用的之后,把该黑色对象标记为黑色的,因此自然是把写屏障插入到指针赋值操作的后面。
然后在重新标记阶段,GC就可以从该灰色对象开始,重新进行扫描。
原始快照
基本原理
G1垃圾收集器解决漏标的办法则是原始快照,原始快照是在灰色对象断开对白色对象的引用时,把被删除的灰色对象到白色对象的引用记录下来,把白色对象修改为灰色。
这样GC就可在最终标记阶段通过该快照引用继续扫描到该白色对象。
由于是在灰色对象断开对白色对象的引用前,把该引用作为快照保存下来,因此是在指针赋值操作前插入的写屏障。
为什么G1使用原始快照而不用增量更新。
我个人觉得,G1之所有使用的是原始快照而不是增量更新,是因为原始快照比起增量更新来说,在最终标记阶段需要扫描的对象更少。
我们根据上面的例子,看看增量更新需要重新扫描的对象:
然后再看看使用原始快照,需要扫描多少个对象:
可以看到,使用增量更新,需要扫描三个对象。而使用原始快照,只需要重新扫描1个对象。
这个例子只有少量几个对象,如果在真实场景下,对象的数量会非常非常多,这样性能差距就很明显了。并且G1它是面向大内存的垃圾收集器,使用原始快照就会比起使用增量更新来说少扫描很多的对象。
记忆集与卡表
JVM的垃圾收集分为年轻代与老年代两个区域,年轻代的对象朝生夕死,老年代的对象存活的时间较长。因此JVM对年轻代的垃圾收集频率肯定是比老年代的垃圾收集频率要高的。
但是在JVM中存在跨代引用的现象,也就是老年代的对象引用了年轻代的对象,或者是年轻代的对象引用了老年代的对象。但是JVM在进行年轻代的垃圾收集时,只会对年轻代进行扫描,不会扫描老年代,那么如果一个年轻代对象仅存在老年代对象对它的引用时,GC是扫描不到它的,自然就不会被标记为存活对象。
那么该对象就会被当成垃圾对象回收,当我们通过老年代对象指向该年轻代对象的引用访问该年轻代对象时,就会发生空指针异常。
因此GC在进行年轻代的垃圾收集时,不能只扫描年轻代的对象,还要扫描老年代中存在跨代引用的对象。如果直接把整个老年代都扫描的话,年轻代GC的性能就太低了,因此JVM定义了一个记忆集(Remember Set),记录非收集区到收集区的引用。比如现在要对年轻代进行垃圾收集,那么此时年轻代区域就是收集区,由于此时不需要对老年代进行垃圾收集,所以老年代就是非收集区。
记忆集只是一个抽象的概念,不同的Java虚拟机对记忆集有不同的实现,比如Hotspot虚拟机对记忆集的实现就是卡表。
首先Hotspot把内存划分为一小块一小块的区域,这些区域叫做卡页,然后定义一个字节数组CART_TABLE[],这个字节数组就是卡表,卡表中的每一个byte,对应内存中的一个卡页。
如果老年代中的一个对象引用了年轻代中的一个对象,那么该老年代对象所在的卡页对应在卡表中的那一个byte就会被修改为1,表示该卡页脏了。
那么当JVM要进行年轻代的垃圾收集时,通过CART_TABLE即可得知老年代区域中哪些卡页是脏页,就会扫描老年代中的脏页,把脏页中的对象加入到GC Roots中。
这样,被老年代对象引用的年轻代对象也可以被扫描到,就不会被当成垃圾对象被回收了。
这个修改卡表对应byte为脏的动作,也是通过写屏障触发的。