深入学习 G1回收器和JVM:混合回收(6)

混合回收可以总结为两个阶段:
  • 并发标记:

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

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

并发标记算法详解

并发标记算法是混合回收中最重要的算法。并发标记指的是标记线程和mutator线程并发运行。
并发标记算法设计了4个指针
  1. Bottom:底部位置
  2. Prev:指向上次并发处理后的地址
  3. Next:指向并发标记开始之前内存已经分配成功的地址
  4. Top:在并发标记开始后,如果有新的对象分配,可以移动top指针,使top指针指向当前内存分配成功的地址。
  • Next和Top之间就是Mutator线程新增的对象使用的地址。
  • 假设Prev之前的对象已经标记成功,在并发标记的时候从根出发,不仅仅标记Prev和Next之间的对象,还标记Prev之前的活跃对象。当并发标记结束之后,只需将- Prev指针设置为Next指针即可开始新一轮的标记处理。
  • 并发标记引入两个位图:

    • PrevBitMap:记录Prev指针之前的内存标记状况
    • NextBitMap:表示整个内存到next指针之前的标记状态
并发标记开始之前:
并发标记开始之前

TAMS指的是Top-at-Mark-Start,并发标记结束后,NextBitMap标记了分区对象的存活情况。假定位图中黑色区域表示堆分区中对应的对象还活着,在并发标记的同时Mutator继续运行,所以Top会继续增长。

第二次标记开始,将NextBitMap值赋给PrevBitMap,将Next指针位置设置为Prev,将Top指针位置设置为Next指针。

并发标记结束状态
并发标记结束状态

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

并发标记第二次结束状态
并发标记第二次结束状态

并发标记算法的难点

主要是GC在标记的时候,mutator线程可能正在改变对象的引用关系图,从而造成漏标和错标。

  • 错标:不会影响程序正确性,只是会产生浮动垃圾
  • 漏标:可能会导致可达对象被当成垃圾回收掉,从而影响程序的正确性。

三色标记法

三色标记法是一个逻辑上的抽象,将对象分成三种颜色
  • 白色:表示还没有被收集器标记的对象
  • 灰色:表示自身已经被标记到,但其拥有的field字段引用到的其他对象还没被处理
  • 黑色:表示自身已经被标记到,且对象本身所有的field引用到的对象也已经被标记。
对象在并发标记阶段会被漏标的充分必要条件是:
  1. Mutator插入了一个从黑色对象到该白色对象的新引用,因为黑色对象已经被标记,如果不对黑色对象重新处理,那么白色对象将会被漏标,造成错误。
  2. Mutator删除了从灰色对象到该白色对象的直接或间接引用,因为灰色对象正在标记,字段引用的对象没有被标记,如果这个引用的白色对象被删除了(引用发生了变化,那么这个引用也有可能被漏标)。

要避免漏标,只要打破上面任意一个即可

  • 通过增量更新算法关注对象的引用插入,把被更新的黑色活着白色的对象标记为灰色,打破第一个条件。
  • SATB关注引用的删除,即在对象被复制前,把老的被引用对象记录下来,然后根据这些对象为根重新标记一遍,打破第二个条件。

混合回收的步骤

  1. 第一阶段:并发标记

    1. 初始标记子阶段
    2. 并发标记子阶段
    3. 再标记子阶段
    4. 清理子阶段
  2. 第二阶段:垃圾回收

    1. 初始标记子阶段:负责标记所有直接可达的根对象(栈对象,全局对象,JNI对象等),根是对象图的起点,因此需要将Mutator线程暂停,需要一次STW
    2. 并发标记子阶段:当YGC结束后,如果发现满足并发标记的条件,并发线程就开始并发标记。根据新生代的Survivor分区以及老生代的RSet开始并发标记。并发标记的时机是在YGC后,只有达到InitiatingHeapOccupancyPercent阈值后,才会触发并发标记。InitiatingHeapOccupancyPercent默认值是45,表示的是当已经分配的内存加上即将分配的内存超过内存总容量的45%就可以开始并发标记,并发标记会对所有分区进行标记,且并不需要STW
    3. 再标记子阶段:是最后一个标记阶段,需要一个STW,找出所有未被访问的存活对象,同时完成存活内存数据计算。要结束标记,需要满足三个条件:

      1. 从根(Survivor)出发,并发标记子阶段已经追踪了所有的存活对象。
      2. 标记栈是空的。
      3. 所有的引用变更都被处理了;这里的引用变更包括新增空间的分配和引用变更,新增的空间所有对象都认为是活的,引用变更处理SATB。
  最后一个阶段很难达成,如果不STW,应用会不断的更新引用,产生新的引用变更,则会永远无法结束标记。
  1. 清理子阶段:需要一个STW,清理子阶段主要执行以下操作:

    1. 统计存活对象:这是利用RSet和BitMap完成的,统计的结果将会用来排序分区gion,以用于下一次CSet的选择;根据SATB算法,需要把新分配的对象,都视为活跃对象。
    2. 交换标记位图:为下次并发准备
    3. 重制RSet:此时老年代分区已经标记完成,如果标记后的分区没有引用对象,这说明引用已经改变,这个时候可以删除原来的RSet里的引用关系。
    4. 把空闲分区放入分区列表中:这里的空间指的是全都是垃圾对象的分区,如果分区还有任何分区活跃对象都不会释放,真正的释放是在混合GC中。
  清理操作不会清理垃圾对象,也不会执行存活对象的拷贝。
  1. 混合回收阶段的分析:选出若干个分区,将这些分区存活的对象复制到空闲的分区去,同时把这些已经被回收的分区放入空闲分区列表
  2. 并发标记的正确性分析:目的是为了识别老生代分区使用的情况,在下一次回收的时候优先选择垃圾比较多的分区进行回收。

并发标记流程

GC活动图

GC线程活动图

并发标记是依赖于YGC,即并发标记发生前一定有一次YGC。在并发标记结束之后,会更新CSetChooser,此时如果在发生GC,则判断是否能够进行混合GC,混合GC的条件是上次发生的YGC不包含初始标记,并且CSetChooser包含有效的分区。

参数优化

  • 参数InitiatingHeapOccupancyPercent(简称为IHOP,默认值为45,这个值是启动并发标记的先决条件,只有当老生代内存占总空间45%之后才会启动并发标记任务。增加该值,将导致并发标记可能花费更多的时间,也会导致YGC或者混合GC收集时收集的分区变少,但另一方面就有可能导致FGC。根据经验这个值通常根据整体应用占用的平均内存来设置,可以把该值设置得比平均内存稍高一些,此时性能最好(即YGC/混合GC比较快,且FGC比较少)。那么如何得到应用程序在运行时的内存使用情况?可以打开G1PrintHeapRegions观察内存的分配和使用情况,另外JVM提供了一个诊断选项G1PrintRegionLivenessInfo,打开该选项,可以查看到内存的使用情况。IHOP的设置非常有用,但是设置合理的IHOP并不容易,需要不断地尝试。
  • 参数G1ReservePercent,默认值为10当发现GC晋升失败导致FGC,可以增大该值
  • 参数ConcGCThreads为并发线程数,默认值为0,如果没有设置则动态调整;使用ParallelGCThreads(前文介绍过推断依据)为依据来推断。ConcGCThreads=(ParallelGCThreads+2)/4,最小值为1,如果发现并发标记耗时较多可以增大该值,注意增大该值会导致Mutator执行的吞吐量变小。
  • 参数HeapSizePerGCThread,默认值为64M,可以简单地理解为每64M分配一个线程
  • 参数UseDynamicNumberOfGCThreads,默认为false,打开该值表示可以动态调整线程数;调整的依据会根据最大线程数、HeapSizePerGCThread等确定。
  • 参数ForceDynamicNumberOfGCThreads,默认为false,打开该值表示可以动态调整,和UseDynamicNumberOfGCThreads功能类似。
  • 参数G1SATBBufferSize,默认值为1K,表示每个STAB队列最多存放1000个灰色对象,注意这里不是SATBqueueset的大小。
  • 参数G1SATBBufferEnqueueingThresholdPercent(默认值是60),表示当一个队列满了之后,首先进行过滤处理,过滤后如果使用率超过这个阈值把队列送入到queueset并新分配一个队列。
  • 参数MarkStackSize和MarkStackSizeMax,在32位JVM中设置为32k和4M,64位JVM中设置为4M和512M。如果没有设置可以启发式推断参数,确保MarkStackSize最小为32k(或者和并发线程参数ParallelGCThreads正相关,如ParallelGCThreads=8,则32位JVM中MarkStackSize=8×16k=128K,其中16k是队列的大小),这个参数是并发标记子阶段中用到的标记栈的大小。
  • 参数GCDrainStackTargetSize,默认值为64,表示并发标记子阶段处理时为了保证处理的性能,一次标记的最多对象个数。
  • 参数G1MixedGCLiveThresholdPercent,默认值85,用于判断分区能否被加入到CSet中,低于该值将会被加入。
  • 参数G1HeapWastePercent,默认值5,即当CSet中可回收空间的占总空间的比例大于G1HeapWastePercent才会开始混合收集。
  • 参数G1MixedGCCountTarget,默认值为8,这个参数越大,收集老生代的分区越少,反之收集的分区越多。要保持老生代分区在CSet中的比例超过1/G1MixedGCCountTarget。
  • 参数G1OldCSetRegionThresholdPercent,参数默认值是10,即一次最多收集10%的分区。
  • 参数G1ConcMarkStepDurationMillis,默认值为10,表示每个并发标记子阶段每次最多执行10ms。
  • 参数G1UseConcMarkReferenceProcessing,默认值为true,打开表示在并发标记的时候可以标记引用。
  • 在打开引用处理时,每次标记处理引用的对象数由G1RefProcDrainInterval控制,默认值为10。
  • 参数ClassUnloadingWithConcurrentMark,默认值为true,打开表示在并发标记的时候可以卸载已经加载的类。
阅读 273

推荐阅读