JVM 垃圾回收器 — G1

垃圾回收器 G1

G1 收集器是一款面向服务端应用的垃圾收集器,主要针对多 CPU 以及大容量内存的场景,在缩短 STW 的同时,具备高吞吐的特征(大概率)。在启动 JVM 参数加上 -XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200 启用 G1 作为垃圾回收器。

G1具备如下特点:

  1. 并行与并发:G1 能更充分的利用多核 CPU,缩短 STW 时间

  2. 分区域、分代收集:分代的概念在 G1 中依然存在(但不再是物理上的划分),G1 可以独自管理整个 GC 堆,以区域(region)为管理单位。

  3. 空间整合:G1 收集器有利于程序长时间运行,从整体看基于“标记-整理”算法实现,从局部看是基于“复制”算法实现

  4. 可预测的非停顿:G1 相对于 CMS 的另一大优势,可建立一个可预测的停顿时间模型,可以指定每次消耗在垃圾收集上的时间不得超过N毫秒。但是会产生另一个问题,垃圾回收的次数会变多。

G1 内存结构

G1 不再按照年轻代年/老代划分堆内存,而是把整个堆空间堆划分成若干个大小相等的内存区域(Region),而在逻辑上一部分区域代表新生代、Survivor 空间,一部分区域代表老年代,新生代的存活对象拷贝到 Survivor 或老年代区域,Survivor 和老年代的存活对象从一块区域拷贝到另一块新的区域,所以不存在碎片问题。

一种特殊的区域,叫 Humongous 区域。如果一个对象占用空间超过了一个分区容量的 50% 以上,G1 就认为这是一个巨型对象。在 CMS 中巨型对象,大概率会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1 划分了一个 Humongous 区,用来专门存放巨型对象。如果一个 H 分区装不下一个巨型对象,那么 G1 会寻找多个连续的 H 分区来存储。为了能找到连续的H区,有时候不得不启动 Full GC。

启动时可以通过参数 -XX:G1HeapRegionSize=n 可指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为 2048 个分区

G1 中的重要数据结构

本地分配缓冲

本地分配缓冲区(Local allocation buffer,Lab)是为了提升分配对象和 GC 的效率而存在的优化算法。

TLAB(Thread Local Allocation Buffer)

用于对小对象分配的优化,在 Eden 区中的一块空间(即一个 Region),用于应用线程独占(因此不存在线程间的竞争),应用线程创建的对象会优先使用该区域(巨型对象或分配失败除外)。每个应用线程都有一个 TLAB。

PLAB(Promotion Local Allocation Buffer)

在 YoungGC 时,将全部 Eden 区存活的对象复制到 Survivor 区域,也会存在 Survivor 区对象晋升(Promotion)到老年代(晋升的阈值可通过 -XX:MaxTenuringThreshold=n 设定)。晋升的过程,无论是晋升到 S 还是 O 区,都是在 GC 线程独占的 PLAB 中进行。每个 GC 线程都有一个 PLAB。

Collection Set(CSet)

CSet 是待回收的 Regions 的集合。CSet 中存放着各个分代的 Regions。GC 后 CSet 中的 Regions 会成为可用分区,而存活的对象都会被转移到分配的空闲分区中。

对于 YoungGC,CSet 只包含年轻代的 Regions;对于 MixedGC,CSet 除了年轻代的 Regions,还会通过算法筛选出老年代中回收收益最高的部分 regions。相关参数:

-XX:G1MixedGCLiveThresholdPercent (默认为一个 Region 分区的 85% 大小),任何一个低于阈值的老年代分区都会被包含在混合收集的 CSet 中。

-XX:G1OldCSetRegionThresholdPercent (缺省为堆的10%),CSet 包含 Regions 的总大小,占堆的比例不超过阈值。

G1 的收集都是根据 CSet 进行操作的,YoungGC 与 MixedGC 没有明显的不同,最大的区别在于两者的触发条件。

Card Table

在每个 region 内部被分成了若干个大小为 512 Byte 的块叫做卡片(Card),即堆内存最小可用粒度。所有分区的卡片将会记录在全局卡片表(Global Card Table)中,分配的对象会占用物理上连续的若干个卡片。

Card Table 的结构是一个字节数组,用单字节的信息映射着一个 Card。当 Card 中存储了对象时,称这个 Card 为 dirty card。

Card table 在 CMS 收集器中也有应用。

Remember Set(RSet)

每个 Region 都会由一个 RSet 维护并跟踪其他 Region 对本 Region 所拥有的引用,也就是存活对象,是一种 points-in 结构。当 Region 中对象被移动时 RSet 也会更新引用。

当 YoungGC 时,通过 RSet 找到引用当前 Region 的 Old 的 Regions 进行扫描。进行 MixedGC 时,同样通过 RSet 找到引用当前 Region 的 Old 的 Regions 进行扫描。避免了扫描全部的 Old 的 Regions,提高扫描效率,并且因为每次 GC 都会扫描所有的年轻代的 Regions,所以查找 RSet 时只需要找到 Old Region 对当前 Region 的引用。

当 Region 被引用较多的情况,RSet 占用空间会上升,因此对 RSet 的记录划分了三种存储粒度:

  • 稀疏表(Sparse):直接通过哈希表来存储,key 是 region index,value 是 card 数组
  • 细粒度(Fine):当一个 region 的 card 数量超过阈值时,为 region 创建一个 PerRegionTable 对象,包含一个 C heap 位图,每一位对应一个 card
  • 粗粒度(Coarse):当 PRT 数量超过阈值时,退化为只记录分区引用情况,由位图存储,每个分区对应一位

SAB(Snaoshot-At-The-Beginning)

SATB 是在 G1 GC 在并发标记阶段使用的增量式的标记算法,解决了 CMS GC 算法中重新标记 STW 时间较长的缺陷。

垃圾回收的并发标记阶段,GC 线程和应用线程是并发执行的,所以一个对象被标记之后,应用线程可能修改了对象的引用关系,从而造成对象的漏标、误标。误标的影响并不严重,可能造成浮动垃圾,在下次 GC 可以被回收;但漏标的后果是致命的,把本应该存活的对象给回收了,影响的程序的正确性。

三色标记法

在了解 SATB 前先了解三色标记法。三色标记法将对象的存活状态用三种颜色标记,从黑色到灰色逐层标记:

  • 黑:根对象,或者该对象以及其引用的对象都被标记完成
  • 灰:该对象被标记了,但其引用的对象还没有全部被标记
  • 白:该对象还没有被标记。标记阶段结束后的白色对象为不可达对象,会被回收。

直观上,这种标记方法不会出现错误,图示:

当垃圾收集器扫描到第二步情况时,应用程序执行

A.c = C;
B.c = null;

标记结果将会变为:

此时 C 被认为是垃圾需要清理掉,出现了漏标的情况(以上只是其中一种情况)。那么如何避免这种情况呢?

CMS 采用的是增量更新(Incremental update),在增加引用时的写屏障(write barrier)里发现有一个”白”对象的引用被赋值到一个”黑”对象的字段里,那就把这个”白”对象变成”灰”的。

G1 使用的是 SATB(snapshot-at-the-beginning),在初始标记(STW)时生产快照记录所有的存活对象。并发标记阶段,所有新建的对象都认为是存活的(解决新建对象漏标的问题),并且记录即将被修改引用关系的白对象的旧引用(satb_mark_queue),在清理阶段,satb_mark_queue 为根进行一遍扫描(解决修改对象漏标的问题),并且 satb_mark_queue 中的对象在下一次并发标记时会被处理。

不论是新建对象还是修改对象都有可能产生浮动垃圾。

GC 过程

JDK10 之前的 G1 GC 只有 YoungGC 和 MixedGC,FullGC 处理会交给单线程的 Serial Old 垃圾收集器。

Young GC

Young GC 主要是对 Eden 区进行垃圾回收,在 Eden 空间耗尽(达到设定的阈值)时会被触发。每次 YoungGC 会回收所有 Eden 以及 Survivor 区,并且将存活对象复制到 Survivor 区或 Old 区(Eden -> Survivor,Eden -> Old,Survivor -> Old,晋升到 Old 区依赖 PLAB 的计算结果),最终 Eden 空间的数据为空,GC 停止工作。

YoungGC 的过程如下:

  1. 扫描 GC Roots 对象,需要 Stop the world
  2. 更新 RSet
  3. 扫描 RSet,找到 Old 区对 Eden 区或者 Survivor 区的引用
  4. 扫描出的存活的对象到拷贝到 Survivor/Old 区

Mixed GC

Mix GC 会对新生代和部分老年代 Regions 进行垃圾回收,老年代的 Regions 根据算法选择加入到 CSet 中。相关控制参数:

-XX:G1HeapWastePercent=n 在一次 YoungGC 之后,可以允许的堆垃圾百占比,超过这个值就会触发 MixedGC。

-XX:G1MixedGCLiveThresholdPercent (默认为一个 Region 分区的 85% 大小),任何一个低于阈值的老年代分区都会被包含在混合收集的 CSet 中。

-XX:G1OldCSetRegionThresholdPercent (缺省为堆的10%),CSet 包含 Regions 的总大小,占堆的比例不超过阈值。

MixedGC 一般会发生在一次 YoungGC 后面,回收过程可以理解为 YoungGC 后进行全局的 concurrent marking(标记 Old/H 区的存活对象),大致过程如下:

  1. 初始标记(InitingMark)

    标记所有的 GC Roots,会 STW,一般会复用 YoungGC 的暂停时间

    [GC pause (G1 Humongous Allocation) (young) (initial-mark), 0.0012359 secs]
    
  2. 根分区标记(RootRegionScan)

    这个阶段 GC 的线程可以和应用线程并发运行。其主要扫描初始标记以及之前 YoungGC 对象转移到的 Survivor 分区,并标记 Survivor 区中引用的对象。所以此阶段的 Survivor 分区也叫根分区(RootRegion)

    5.551: [GC concurrent-root-region-scan-start]
    5.552: [GC concurrent-root-region-scan-end, 0.0001604 secs]
    
  3. 并发标记(ConcurrentMark)

    并发标记阶段是并发且多线程,标记所有非空闲分区的存活对象,使用了 SATB 算法。

    默认会使用 -XX:ParallelGCThreads=n 线程总数的1/4来进行并发标记,或者使用 -XX:ConcGCThreads=n 来设置。

    5.552: [GC concurrent-mark-start]
    5.580: [GC concurrent-mark-end, 0.0282578 secs]
    
  4. 重新标记(Remark)

    主要处理并发标记阶段未标记到的存活对象(可以参考上文的 SATB 处理),这个阶段会 STW。

    [GC remark 5.581: [Finalize Marking, 0.0000953 secs] 5.581: [GC ref-proc, 0.0000619 secs] 5.582: [Unloading, 0.0017910 secs], 0.0020940 secs]
    [Times: user=0.01 sys=0.00, real=0.00 secs]
    

    过多使用引用对象(弱,软,虚)会导致重新标记时间过长。

  5. 清除(Cleanup)

    在清除阶段会 STW,主要工作:1. SATB 会进行缓存/指针的更新;2. 识别所有空闲分区;3. 识别出回收效率高的老年代分区;4. 更新 RSet;

    5.584: [GC concurrent-cleanup-start]
    5.584: [GC concurrent-cleanup-end, 0.0000046 secs]
    

清除阶段之后,会对存活对象进行转移(复制算法)到其他可用分区,所以当前的分区就变成了新的可用分区。主要是为了解决分区内的碎片问题。

Full GC

G1 在对象复制/转移失败或者没法分配足够内存(比如巨型对象没有足够的连续分区分配)时,会触发 FullGC。FullGC 使用的是单线程的 Serial Old 回收器,所以一旦触发 FullGC 则会 STW 应用线程,并且执行效率很慢。

[GC pause (G1 Evacuation Pause) (mixed)... (to-space exhausted), 0.1145790 secs]

G1 的使用场景

G1 的首要目标是为有大容量内存的系统提供一个保证 GC 低延迟的解决方案,堆内存在 6GB 及以上,保证稳定和可预测的暂停时间。

Tags:

Add a Comment

电子邮件地址不会被公开。 必填项已用*标注