0%

G1 MixedGC过程

混合回收(Mixed GC,也称为混合 GC)既收集 YHR 也收集 OHR。因为涉及老生代的回收,通常来说老生代的空间比较大,收集老生代可能会花费更多的时间。所以涉及老生代的混合收集算法也不同于新生代回收算法,最明显的是引入并发标记,这里的并发标记指的是标记工作线程可以和 Mutator 同时运行,也就是标记垃圾对象准备回收的线程和生成垃圾对象的线程同时工作,这就引入了新的复杂度。

并发标记算法

并发标记算法过程

并发标记算法是混合回收中最重要的算法。并发标记指的是标记线程和 Mutator 并发运行。那么标记线程如何并发地进行标记?正如我们前面提到的并发标记的难点,一边标记垃圾对象,一边还在生成垃圾对象。为了解决这个问题,以前的算法采用串行执行,这里的串行指的是标记工作和对象生成工作不同时进行,但在 G1 中引入了新的算法。在介绍并发标记算法之前我们首先回顾一下对象分配,再来讨论这个问题。

算法需要区分标记过和未标记过的内存段

在堆分区中分配对象都是连续的,因此在堆分区没有被回收之前,分区中占用的内存只能是增长的。

因此算法通过四个指针来定义地址段:

  1. Bottom、Top:定义当前 Region 已分配内存区域
  2. Prev、Next:定义并发标记工作内存区域

我们来详细解释一下这四个指针的含义,算法用 Prev 指针指向上一次并发处理后的地址,用 Next 指向并发标记开始之前内存已经分配成功的地址,当并发标记开始之后,如果有新的对象分配,可以移动 Top 指针,使 Top 指针指向当前内存分配成功的地址。Next 指针和 Top 指针之间的地址就是 Mutator 新增的对象使用的地址。

并发标记开始之前

TAMS = Top-at-Mark-Start

如果我们假设 Prev 指针之前的对象已经标记成功,在并发标记的时候从根出发,不仅仅标记 Prev 和 Next 之间的对象,还标记了 Prev 指针之前活跃的对象。当并发标记结束之后,只需要把 Prev 指针设置为 Next 指针即可开始新一轮的标记处理。

算法需要区分标记过和未标记过的对象位置

我们已经能区分内存中哪一段被我们标记过,我们还需要记录标记过的内存段中哪些对象活跃。借鉴前面介绍过的方法,Bitmap 能很好的解决这个问题。

并发标记结束状态

所以并发标记引入两个位图 PrevBitMap 和 NextBitMap,用 PrevBitmap 记录 Prev 指针之前内存的标记状况,用 NextBitmap 来表示 Bottom 到 Next 指针之间的标记状态。

为什么要引入 Prev 和 Next 两个 Bitmap?只要一个就可以记录到对象的状态啊!

原因是标记不是每次都会成功的,存在失败的可能。如果标记失败,会丢失上一次对 Prev 指针之前所有的标记状况,因此需要留一个存档来避免因标记失败导致的重新标记整个内存。

如上图所示,Prev 就是前一次标记的地址即 Prev TAMS,Next 指向的是当前开始标记时最新的地址即 Next TAMS。并发标记会从根对象出发开始进行并发标记。在第一次标记时 PrevBitmap 为空,NextBitmap 待标记。

并发标记第二次开始之前状态

并发标记结束后,NextBitmap 标记了分区对象存活的情况,如上图所示。

新一轮的并发标记

假定上图的位图中黑色区域表示堆分区中对应的对象还活着。在并发标记的同时 Mutator 继续运行,所以 Top 会继续增长。新一轮的并发标记开始,交换位图,重置指针。如下图所示。

并发标记第二次结束状态

并发标记难点

并发标记的主要问题是垃圾回收器在标记对象的过程中,Mutator 可能正在改变对象引用关系图,从而造成漏标和错标。错标不会影响程序的正确性,只是造成所谓的浮动垃圾。但漏标则会导致可达对象被当做垃圾收集掉,从而影响程序的正确性。为了区别对象的不同状态,引入了三色标记法。

三色标记法

三色标记法是一个逻辑上的抽象,将对象分成三种颜色:

  1. 白色(white),表示还没有被收集器标记的对象
  2. 灰色(gray),表示自身已经被标记到,但其拥有的 field 字段引用到的其他对象还没有被处理
  3. 黑色(black),表示自身已经被标记到,且对象本身所有的 field 引用到的对象也已经被标记。

对象在并发标记阶段会被漏标的充分必要条件是:

  1. Mutator 插入了一个从黑色对象到该白色对象的新引用,因为黑色对象已经被标记,如果不对黑色对象重新处理,那么白色对象将被漏标,造成错误。
  2. Mutator 删除了所有从灰色对象到该白色对象的直接或者间接引用,因为灰色对象正在标记,字段引用的对象还没有被标记,如果这个引用的白色对象被删除了(引用发生了变化),那么这个引用对象也有可能被漏标。

因此,要避免对象的漏标,只需要打破上述两个条件中的任何一个即可。

所以在并发标记的时候也对应地有两种不同的实现:

  1. 增量更新算法:关注对象引用插入,把被更新的黑色或者白色对象标记成灰色,打破第一个条件。
  2. SATB:关注引用的删除,即在对象被赋值前,把老的被引用对象记录下来,然后根据这些对象为根重新标记一遍,打破第二个条件。

漏标的场景

并发标记的中间状态

已知对象 1 和对象 2 都可以通过根对象到达。假定对象 1 已经被标记,所以设置为黑色。处理完对象 1 会把对象 1 的 field 指向的对象地址放入到待标记栈。当对象 2 已经标记完成,需要把对象 2 的 field 指向的对象栈,即对象 3 入栈待处理。如果此时并发标记线程让出 CPU,Mutator 执行并修改了引用关系。对象2.field=NULL对象1.field=对象3,如下图所示:

Mutator运行导致关系变更

这时候并发线程重新获得执行,将会发生什么?对象 1 已经变成黑色,说明 field 都标记完了。对象 2 灰色待处理 field,但是 field 已经为NULL,所以不需要处理。那么对象 3 怎么办?如果不进行额外的处理就会导致漏标。这里就需要上面提到的两种解决漏标的方法。

增量更新

增量更新的思路就是当发生了 对象1.field=对象3,就把对象 1 重新标记为灰色,意味着对象 1 的 field 需要被再次处理一遍,如下图所示:

增量更新算法

第二种方法就是 SATB,这个思路就是在发生 对象2.field=NULL 之前把老的 对象2.field 指向的对象 3 放入待标记栈中,相当于把对象 3 设置成灰色,如下图所示。

SATB更新算法

MGC 算法过程

混合回收可以总结为两个阶段:并发标记、垃圾回收。

并发标记过程

并发标记目的是识别老生代分区中的活跃对象,并计算分区中垃圾对象所占空间的多少,用于垃圾回收过程中判断是否回收分区。

初始标记子阶段

初始标记是借助 YGC 阶段完成,这里我们仅仅关心和并发标记相关的部分。

初始标记子阶段负责标记所有直接可达的根对象(栈对象、全局对象、JNI对象等),根是对象图的起点,因此初始标记需要将 Mutator 线程暂停,也就是需要一个STW的时间段。混合收集中的初始标记和新生代的初始标记几乎一样。实际上混合收集的初始标记是借用了新生代收集的结果,即新生代垃圾回收后的新生代 Survivor 分区作为根,所以混合收集一定发生在新生代回收之后,且不需要再进行一次初始标记。

YGC结束状态

F:Free Region
S:Survivor Region
E:Eden Region
Old:Old Region

在 YGC 阶段时,首先判定是否需要进行并发标记,如果需要,在对象复制阶段当发现有根集合直接到老生代的引用,那么这些对象会在 YGC 阶段被标记,如上图中 nextMarkBitmap 所示。

根扫描子阶段

根扫描主要是针对 Survivor 分区进行处理,所有的 Survivor 对象都将被认为是老生代的根,如下图所示。

并发标记根扫描

注意这一阶段仅仅对 Survivor 里面的对象标记,而不会处理对象的 field。图中深色区域是新增的活跃对象对应的区域。

这里需要注意,因为混合 GC 依赖于 YGC 的 Survivor 区,可能发生这样一种情况,当混合 GC 扫描还没有结束,如果又发生了 YGC,那么 Survivor 就会变化,这对混合 GC 来说是不可接受的,因为它不能准确地标记对象。所以在混合 GC 的时候一定会要求做完 Survivor 分区的扫描之后才能再进行一次新的 YGC。

并发标记子阶段

并发标记的时机是在 YGC 后,只有达到 InitiatingHeapOccupancyPercent 阈值后,才会触发并发标记。InitiatingHeapOccupancyPercent 默认值是 45,表示的是当已经分配的内存加上即将分配的内存超过内存总容量的 45% 就可以开始并发标记。并发标记线程在并发标记阶段启动,每个线程每次只扫描一个分区,从而标记出存活对象。在标记的时候还会计算存活的数量(Live Data Accounting),只要一个对象被标记,同时会计算字节数,并计入分区空间,这和并发算法相关。并发标记会对所有的分区进行标记。这个阶段并不需要 STW,故标记线程和 Mutator 并发运行。

并发标记子阶段主要处理 SATB 队列,然后选择分区,根据 nextMarkBitmap 中已经标记的信息,对标记对象的每一个 field 指向的对象递归地进行标记。这里的并发标记子阶段指的是,每个并发标记线程从全局的指针开始抢占分区(其实就是使用 CAS 指令进行串行处理),所有线程停止的条件就是所有的分区处理完毕。

并发标记子阶段结束状态

在上图中 SATB 队列的处理可能会涉及所有的分区,然后根据分区递归处理已经标记的对象的 Field,直到所有的分区处理完毕。

在并发标记子阶段中如果待标记对象过多,可能导致标记栈溢出,这个时候会再次循环处理根标记和并发标记子阶段。

再标记子阶段

再标记(Remark)是最后一个标记阶段。在该阶段中,G1 需要 STW,找出所有未被访问的存活对象,同时完成存活内存数据计算。引入该阶段的目的,是为了能够达到结束标记的目标。要结束标记的过程,要满足三个条件:

  1. 从根(Survivor)出发,并发标记子阶段已经追踪了所有的存活对象。
  2. 标记栈是空的。
  3. 所有的引用变更都被处理了;这里的引用变更包括新增空间分配和引用变更,新增的空间所有对象都认为是活的,引用变更处理 SATB。

前两个条件是很容易达到的,但是最后一个是很困难的。如果不引入一个 STW 的再标记过程,那么应用会不断地更新引用,也就是说,会不断产生新的引用变更,因而永远也无法达成完成标记的条件。这个阶段也是并行执行的,通过参数 -XX:ParallelGCThreads 可设置GC暂停时可用的GC线程数。同时,引用处理也是再标记阶段的一部分,所有重度使用引用对象(弱引用、软引用、虚引用、最终引用)的应用都需要不少的开销来处理引用。

再标记子阶段结束状态

再标记子阶段结束之后整个分区都已经完成了标记。

清理子阶段

再标记阶段之后进入清理子阶段,也是需要 STW 的。清理子阶段主要执行以下操作:

  1. 统计存活对象,这是利用 RSet 和 BitMap 来完成的,统计的结果将会用来排序 Region,以用于下一次的 CSet 的选择;根据SATB算法,需要把新分配的对象,即不在本次并发标记范围内的新分配对象,都视为活跃对象。
  2. 交换标记位图,为下次并发标记准备。
  3. 重置 RSet,此时老生代分区已经标记完成,如果标记后的分区没有引用对象,这说明引用已经改变,这个时候可以删除原来的 RSet 里面的引用关系。
  4. 把空闲分区放到空闲分区列表中;这里的空闲指的是全都是垃圾对象的分区;如果分区还有任何分区活跃对象都不会释放,真正释放是在混合 GC 中。该阶段比较容易引起误解地方在于,清理操作并不会清理垃圾对象,也不会执行存活对象的拷贝。也就是说,在极端情况下,该阶段结束之后,空闲分区列表将毫无变化,JVM 的内存使用情况也毫无变化。

并发标记的结果其实就是把垃圾比较多的老生代分区加入到 CSet Chooser,那么标记的时候为什么要对整个堆的所有分区逐一标记?

首先,根扫描主要是针对 Survivor 分区进行处理,但结果是把垃圾比较多的老生代分区加入到 CSet。但事实上,我们还需要考虑 GCRoots 直接引用的老年代的情况,在只处理 Survivor 分区时,这些活跃对象会被漏标,最终导致被错误地清理。因此,最终只是为了回收的安全性。

清理阶段结束状态如下图所示。

清理阶段结束状态

垃圾回收过程

垃圾回收和新生代回收的步骤完全一致,重用了新生代回收的代码,最大的不同是在回收时不仅仅回收新生代分区,同时回收并发标记中识别到的垃圾多的老生代分区。

MGC 过程日志

Evacuation Pause 除了是 YGC 触发之外,还可能是 Mixed GC,日志如下所示,Mixed GC 的整个子任务和 YGC 完全一样,只是回收的范围(CSet)不一样而已,YGC 只回收 Young 区,而 Mixed GC 回收 Young 区+部分 Old 区:

29.268: [GC pause (G1 Evacuation Pause) (mixed), 0.0059011 secs]
[Parallel Time: 5.6 ms, GC Workers: 4]
… …
[Code Root Fixup: 0.0 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.1 ms]
[Other: 0.3 ms]
… …
[Eden: 14.0M(14.0M)->0.0B(156.0M) Survivors: 10.0M->4096.0K Heap: 165.9M(512.0M)->148.7M(512.0M)]
[Times: user=0.02 sys=0.01, real=0.00 secs]

MGC 参数

InitiatingHeapOccupancyPercent

参数 InitiatingHeapOccupancyPercent(简称为IHOP),默认值为45,这个值是启动并发标记的先决条件,只有当老生代内存占总空间 45% 之后才会启动并发标记任务。增加该值,将导致并发标记可能花费更多的时间,也会导致 YGC 或者混合 GC 收集时收集的分区变少,但另一方面就有可能导致 FGC。

根据经验这个值通常根据整体应用占用的平均内存来设置,可以把该值设置得比平均内存稍高一些,此时性能最好(即 YGC/混合GC 比较快,且 FGC 比较少)。

那么如何得到应用程序在运行时的内存使用情况?可以打开 G1PrintHeapRegions 观察内存的分配和使用情况,另外 JVM 提供了一个诊断选项 G1PrintRegionLivenessInfo,打开该选项,可以查看到内存的使用情况。IHOP 的设置非常有用,但是设置合理的 IHOP 并不容易,需要不断地尝试。

G1ReservePercent

通过 -XX:G1ReservePercent 指定 G1 为分配担保预留的空间比例,默认 10%。也就是老年代会预留 10% 的空间来给新生代的对象晋升,如果经常发生新生代晋升失败而导致 Full GC,那么可以适当调高此阈值。但是调高此值同时也意味着降低了老年代的实际可用空间。

ConcGCThreads

参数 ConcGCThreads 为并发线程数,默认值为 0,如果没有设置则动态调整;使用 ParallelGCThreads(前文介绍过推断依据)为依据来推断。ConcGCThreads=(ParallelGCThreads+2)/4,最小值为 1,如果发现并发标记耗时较多可以增大该值,注意增大该值会导致 Mutator 执行的吞吐量变小。

HeapSizePerGCThread

参数 HeapSizePerGCThread,默认值为 64M,可以简单地理解为每 64M 分配一个线程。

UseDynamicNumberOfGCThreads

参数 UseDynamicNumberOfGCThreads,默认为 false,打开该值表示可以动态调整线程数;调整的依据会根据最大线程数、HeapSizePerGCThread 等确定。

ForceDynamicNumberOfGCThreads

参数 ForceDynamicNumberOfGCThreads,默认为 false,打开该值表示可以动态调整,和 UseDynamicNumberOfGCThreads 功能类似。

G1SATBBufferSize

参数 G1SATBBufferSize,默认值为 1K,表示每个 STAB 队列最多存放 1000 个灰色对象,注意这里不是 SATB queue set 的大小。

G1SATBBufferEnqueueingThresholdPercent

参数 G1SATBBufferEnqueueingThresholdPercent(默认值是60),表示当一个队列满了之后,首先进行过滤处理,过滤后如果使用率超过这个阈值把队列送入到 queue set 并新分配一个队列。

MarkStackSize 和 MarkStackSizeMax

参数 MarkStackSize 和 MarkStackSizeMax,在 32 位 JVM 中设置为 32K 和 4M,64 位 JVM 中设置为 4M 和 512M。如果没有设置可以启发式推断参数,确保 MarkStackSize 最小为 32k(或者和并发线程参数 ParallelGCThreads 正相关,如 ParallelGCThreads=8,则 32 位 JVM 中 MarkStackSize=8×16k=128K,其中 16K 是队列的大小),这个参数是并发标记子阶段中用到的标记栈的大小。

GCDrainStackTargetSize

参数 GCDrainStackTargetSize,默认值为 64,表示并发标记子阶段处理时为了保证处理的性能,一次标记的最多对象个数。

G1MixedGCLiveThresholdPercent

参数 G1MixedGCLiveThresholdPercent,默认值 85,用于判断分区能否被加入到 CSet 中,低于该值将会被加入。

G1HeapWastePercent

参数 G1HeapWastePercent,默认值 5,即当 CSet 中可回收空间的占总空间的比例大于 G1HeapWastePercent 才会开始混合收集。

G1MixedGCCountTarget

参数 G1MixedGCCountTarget,默认值为 8,这个参数越大,收集老生代的分区越少,反之收集的分区越多。要保持老生代分区在 CSet 中的比例超过 1/G1MixedGCCountTarget

G1OldCSetRegionThresholdPercent

参数 G1OldCSetRegionThresholdPercent,参数默认值是 10,即一次最多收集 10% 的分区。

G1ConcMarkStepDurationMillis

参数 G1ConcMarkStepDurationMillis,默认值为 10,表示每个并发标记子阶段每次最多执行 10ms。

G1UseConcMarkReferenceProcessing

参数 G1UseConcMarkReferenceProcessing,默认值为 true,打开表示在并发标记的时候可以标记引用。在打开引用处理时,每次标记处理引用的对象数由 G1RefProcDrainInterval 控制,默认值为 10。

ClassUnloadingWithConcurrentMark

参数 ClassUnloadingWithConcurrentMark,默认值为 true,打开表示在并发标记的时候可以卸载已经加载的类。