垃圾回收器一文中了解了垃圾回收器,从CMS垃圾回收器开始,为了缩短STW的时间,都引进了并发垃圾回收的阶段,本文主要学习一下各个垃圾回收器并发垃圾回收的原理。
CMS
CMS收集器将整个垃圾回收过程,实际上分为了五个阶段(这里采用Oracle教程说法,其余教程里说的是四个阶段):
- 初始标记(Initial Mark)。标记GC Root能直达的对象,STW
- 并发标记(Concurrent Mark)。在应用程序不暂停的情况下,以GC Roots为起点,遍历所有可达对象。在并发标记的过程中,应用程序有可能修改对象之间的引用关系,导致并行标记过程出现误标或漏标的情况。
- 最终标记(Remark)。在步骤②中应用程序可能更改了对象的引用关系,导致出现了误标或者漏标的情况,这些情况可以使用Incremental Update或者SATB方法进行修正。STW
- 并发清理(Concurrent Sweep)。在应用程序不暂停的情况下,并发清理标记为死亡对象,这个阶段不会涉及到存活对象的移动。
- 重置(Resetting)。此阶段与应用程序并发执行,重置CMS算法相关的内部数据,为下一次 GC 循环做准备。
初始标记
初始标记指的是标记GC Roots。
JVM如何判断一个对象是否可以被回收一文中,介绍了三色算法,主要用于标记GC ROOTS。
对应步骤如下:
- 初始化将GC Roots标记为灰色,并放入灰色集合,剩余的其他节点标记为白色,并放入白色集合。
- 从灰色集合中取一个灰色对象,标记为黑色,放入黑色集合,并将此对象直接引用的所有白色对象标记为灰色,并放入灰色集合。
- 重复上述第2)步,直到灰色集合中没有对象为止。此时,黑色集合中存放的就是可达对象,也就是存活对象,白色集合中存放的就是不可达对象,也就是死亡对象。
在上述的处理过程中,步骤1)被称为初始标记阶段,步骤2)和3)为遍历过程,在并发垃圾回收中,可以与应用程序并发执行,因此,被称为并发标记阶段。
所以在初始标记阶段,只标记直接关联GC root的对象,不用向下追溯。因为最耗时的就在tracing阶段,这样就极大地缩短了初始标记时间。
这里除了要标记相关的 GC Roots 之外,还要标记年轻代中对象的引用,这也是 CMS 老年代回收,依然要扫描新生代的原因。
并发标记
应用程序工作的同时,拿部分资源做GC的事,而不需要STW。那么代价是什么呢?
- GC吞吐变差。如果完全停顿,GC可以占用所有资源,现在单位时间内只能用部分资源,GC 的吞吐必然变差。
- GC实现更复杂。例如三色算法,考虑的是"静态"的引用树,但在并发模式下,标记到一半时,树的结构可能发生变化,于是算法得有相关的实现来处理这些情况。
吞吐问题是效率问题,基本只能靠不断优化算法与实现细节。引用结构变化问题是正确性问题,一般分为两种情况:误标和漏标。
误标
指将非存活对象标记为存活对象。假设,B对象已经被标记为Grey,此时执行了a.b = null
断开了引用,但是B及其引用对象C,D仍然被判定为存活对象,从而产生了误标现象。
ini
public class A{
public B b;
public A(B b){
this.b = b;
}
}
public class B{
public C c;
public D d;
public B(C c, D d){
this.c = c;
this.d = d;
}
static class C {
}
static class D{
}
}
C c = new C();
D d = new D();
B b = new B(c, d);
A a = new A(b);
a.b = null;
漏标
漏标是指将存活对象漏标,导致存活对象被判定为死亡对象。
漏标会有两种情形:
- 新增对象,在标记期间新增的对象通过旧的GC Roots可能不可达,标记结束后可能还是White,导致被当成死亡对象而被回收。
- 修改引用关系,在标记期间,如果应用程序新增了Black对White对象的引用,并且应用程序断开了Grey对White的引用,同时满足这两个条件,则会产生漏标情况。标记过程会遗漏这个White对象,因为通过Grey对象不可达,且Black对象不会被二次扫描。于是GC结束后它会被释放,但它同时还被Black对象引用着,程序会出错。
重新标记
重新标记所做的工作就是对误标或漏标进行修正,在这个阶段需要STW。
误标实际上只要重新标记一下或者下一次GC的时候,就会修正好。
漏标在重新标记阶段需要费些心思了。主要有两种方法:增量更新和原始快照,CMS采用的是增量更新,而G1采用的是原始快照
增量更新(Incremental Update)
在并发标记过程中,JVM会记录每一个新增的Black对象对White对象引用中的White对象,把它标记为Grey。然后在重新标记阶段,会以这些对象为起点,重新进行可达性分析。这样漏标的White对象,最终都会被重新标记为Black。
实际上,增量更新的主要目的就是为了破坏条件一。
原始快照(Snapshot At The Beginning)
SATB的想法则是破坏条件二,在标记开始之前做快照,快照之后新增的对象都不处理,认为是Black;当删除旧引用的时候,记录旧的引用,这样在标记结束前再扫描这些旧的引用即可,这样原先的Grey -> White的引用虽然断开了,但White对象依旧可以扫到。
不过,原始快照记录下的白色对象有可能是死亡对象,而重新标记阶段会将这些死亡对象重新标记为存活对象,因此,原始快照这种解决方案会导致误标问题,不过,前面讲到,相对于漏标问题,误标问题不大,是可以接受的。
SATB的实现原理是通过write-barrier实现的。
并发清理
并发清理指的是在不暂停应用程序的情况下,对标记出来的垃圾对象进行清理。
在并发清理阶段,对象的引用关系也有可能发生改变。存活对象有可能会变为死亡对象,但这些对象只需要在下一次垃圾回收中被回收即可。反过来,由于死亡对象不会再用引用关系,所以在应用程序中是无法使用这些死亡对象的。
如果在并发清理阶段,应用程序新建了对象,JVM会直接把这个对象标记为Black。
并发重置
此阶段与应用程序并发执行,重置CMS算法相关的内部数据,为下一次 GC 循环做准备。
G1
G1 GC最主要的设计目标是:将 STW 停顿的时间和分布,变成可预期且可配置的。
G1的整体流程如下:
- concurrent marking(并发标记)。在尽量不暂停应用程序的前提下标记出存活对象,还需要记录每个区域存活对象的数量,这个信息evacuation时会使用。
- evacuation(比较难翻译,有翻译成"迁移"或"转移"的,这里保留英文)。负责将还存活的对象复制到其它空闲区域,将待回收的区域重新标记为空闲。
要注意的是,并发标记与evacuation是相互独立的,就算最新的标记任务做到一半,evacuation还是可以利用之前的标记结果做回收的,毕竟G1每次回收并不会回收所有区域。
参考Oracle官网详细步骤如下:
- 初始标记(Initial Mark)。标记GC Root能直达的对象,标记可能在老年代有引用对象的Survivor区域,通常都是伴随着Young GC一起发生的。STW
- 根区域扫描(Root Region Scan)。标记所有从root region可达的对象(即①中提到的Survivor区域)。该步骤是为了处理标记与evacuation并发执行引发的一些问题。不需要STW,但需要在下次evacuation前完成
- 并发标记(Concurrent Mark)。使用三色算法标记所有可达的对象。与应用程序可并发执行
- 最终标记(Remark)。在步骤②、③中应用程序可能更改了对象的引用关系,这些关系用SATB写屏障做了记录,该步骤会扫描这些记录并做标记。该过程的标记性能会比CMS垃圾回收器更快,STW
- 清理(Cleanup)。主要包含几部分
-
- 存活对象计数。统计每个区域的存活对象信息,供evacuation使用。STW
- 清理Remembered Sets(RSet)。STW
- 释放空区域。并发执行。
- 复制(Copying)。需要STW,将存活对象evacuate或者copy到一块新的未使用到的regions中,然后将这些regions分别标记为年轻代,或者年轻代和老年代的混合。
初始标记
Initial Mark阶段会扫描所有的GC Root,将GC Root直接引用的对象标记为灰色(加入待遍历的队列),这个过程是STW的。Initial Mark阶段其实是触发一次Young GC(在G1中称为Fully Young Evacuation),在Young GC对GC Root扫描的过程中顺便做标记。因此在文档上,会说Initial Mark这个操作是"piggy-backed"(趴在猪上,搭载的意思)。
根区域扫描
Rset
在了解根区域扫描的逻辑之前,我们先来学习一下RSet(Remembered Set)数据结构。还有一种数据结构也是辅助GC的:Collection Set(CSet),它记录了GC要收集的Region集合,集合里的Region可以是任意年代的。
在GC的时候,对于old->young和old->old的跨代对象引用,只要扫描对应的CSet中的RSet即可。 逻辑上说每个Region都有一个RSet,RSet记录了其他Region中的对象引用本Region中对象的关系,属于points-into结构(谁引用了我的对象)。而Card Table则是一种points-out(我引用了谁的对象)的结构,每个Card 覆盖一定范围的Heap(一般为512Bytes)。G1的RSet是在Card Table的基础上实现的:每个Region会记录下别的Region有指向自己的指针,并标记这些指针分别在哪些Card的范围内。 这个RSet其实是一个Hash Table,Key是别的Region的起始地址,Value是一个集合,里面的元素是Card Table的Index。
有了这个数据结构,在回收某个Region的时候,就不必对整个堆内存的对象进行扫描了。它使得部分收集成为了可能。
对于年轻代的Region,它的RSet只保存了来自老年代的引用,这是因为年轻代的回收是针对所有年轻代Region 的,没必要画蛇添足。所以说年轻代Region的RSet有可能是空的。
而对于老年代的Region来说,它的RSet也只会保存老年代对它的引用。这是因为老年代回收之前,会先对年轻代进行回收。这时,Eden区变空了,而在回收过程中会扫描Survivor分区,所以也没必要保存来自年轻代的引用。
RSet通常会占用很大的空间,大约5%或者更高。不仅仅是空间方面,很多计算开销也是比较大的。
事实上,为了维护RSet,程序运行的过程中,写入某个字段就会产生一个post-write barrier 。为了减少这个开销,将内容放入RSet的过程是异步的,而且经过了很多的优化:Write Barrier把脏卡信息存放到本地缓冲区(local buffer),有专门的GC线程负责收集,并将相关信息传给被引用Region的RSet。
根区域扫描
我们知道标记和Young GC可以并发执行,如果并发标记进行到一半,Young GC开始执行,那么Young GC除了复制存活对象外,还需要维护标记相关的状态,这个操作是比较耗时的
GC做回收的时候,还是会自己扫描对象引用的,并不会直接复用标记的结果。由于G1每次只回收部分区域,如果扫描引用需要扫描整个堆,就太浪费时间了,所以RSet就是其中一个优化,只需要找到需要回收区域的RSet,就能知道有哪些区域引用自己了。
由于不管是Young GC还是Mixed GC,Eden和Survivor的所有区域都会被回收,所以这些区域的mark信息本质上是没用的,因为evacuation时本来就会扫描它们中的所有对象。于是可以推论标记的产出,是Old Gen里不被回收的区域中对象的存活信息。
而我们知道Young GC只回收Eden和Survivor,过程中需要维护标记信息是防止移动Eden和Survivor的对象导致扫描出错。那只需要在并发Young GC发生之前,就把Young Gen的对象都扫完就行了。扫完后Young Gen发生的事都跟并发标记无关了。
由于Initial Mark本质上是做了一次Young GC,可以认为Initial Mark快照时,Eden是空的,所以在下次Young GC前,只需要扫完Survivor区即可。这个过程被称为根区域扫描,而Survivor区也是目前唯一的根区域。
并发标记
实际上G1的并发标记与CMS的类似,但G1是通过SATB算法来解决并发标记的问题。
最终标记
与CMS收集器类似,重新标记也是为了标记在并发阶段中对象的引用关系发生变化的对象,解决并发标记阶段引起的正确性问题,实际上
清理
从 Oracle 的文档来看,清理工作就三件事:
- 存活对象计数。统计每个区域的存活对象信息,供evacuation使用。STW
- 清理Remembered Sets(RSet),标记阶段能确定一些空区域,它们的RSet可以清理。STW
- 释放空区域。并发执行。
复制
需要STW,以便evacuate或者copy存活的对象到新建未被使用的Regions区域。
Mixed GC
能并发清理老年代中的整个整个的小堆区是一种最优情形。混合收集过程,不只清理年轻代,还会将一部分老年代区域也加入到 CSet中。
通过Concurrent Marking阶段,我们已经统计了老年代的垃圾占比。在Minor GC之后,如果判断这个占比达到了某个阈值,下次就会触发Mixed GC。这个阈值,由-XX:G1HeapWastePercent
参数进行设置(默认是堆大小的 5%)。因为这种情况下, GC会花费很多的时间但是回收到的内存却很少。所以这个参数也是可以调整Mixed GC 的频率的。
还有参数G1MixedGCCountTarget
,用于控制一次并发标记之后,最多执行Mixed GC的次数。