Java 垃圾收集算法:G1


概述

设计 G1 的核心目标之一就是,GC 的时长和 GC 导致的应用暂停的分布要可控:可预期、可配置。事实上,G1 是一个软实时垃圾收集算法,这意味着我们可以设置它的性能指标,比如,1 秒内的 GC 暂停时长不超过 5 毫秒。G1 会努力以大概率达到这个要求,但不是一定,否则就是硬实时了。

为了这个目标,G1 有一些独特的实现方式。首先,堆空间不必分割成物理上连续的年轻代老年代,而是被分成了多块(通常是2048)存放对象的小区域,每块区域都可能是伊甸区存活区或者老年区。所有在逻辑上是伊甸区存活区的区域集合就是年轻代,所有老年区的集合就是老年代

g1-011-591x187.png

这样,GC 时不需要一次性收集整个堆,而是增量式的去做垃圾收集:一次只处理一部分区域,也就是收集区集合CSet。每次暂停都会收集所有年轻区,也可能会收集部分老年区

g1-02-591x187.png

在并发阶段,G1 的另一个创新之处在于它会猜测每块区域中存活对象的数量。在创建CSet时候,这点就派上用场了:包含最多垃圾对象的区域优先被收集,这也是它名字的由来,Garbage First。

你可以像下面这样为你的 JVM 使用 G1 垃圾收集器。

1
java -XX:+UseG1GC com.mypackages.MyExecutableClass

疏散暂停:年轻模式(Evacuation Pause: Fully Young)

应用启动的初始阶段,G1 还没有从并发阶段收集来的附加信息,所以它只能工作在年轻模式。当年轻代被占满,应用线程被暂停,所有年轻区中的存活对象被复制到一个或多个存活区,或者说数据复制到的空闲区都变成了存活区

复制的过程被称为疏散(Evacuation),这和以前年轻代的垃圾收集器的工作方式是类似的。疏散暂停的全部日志是很大的,简单起见,我们省去部分与年轻模式下的疏散暂停不相关的部分,在了解过并发阶段细节之后,我们再来看它们。此外,我们从日志中抽出并行阶段其他阶段的细节到单独的部分去说:

0.134: [GC pause (G1 Evacuation Pause) (young), 0.0144119 secs]
[Parallel Time: 13.9 ms, GC Workers: 8]

[Code Root Fixup: 0.0 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.1 ms]
[Other: 0.4 ms]

[Eden: 24.0M(24.0M)->0.0B(13.0M) Survivors: 0.0B->3072.0K Heap: 24.0M(256.0M)->21.9M(256.0M)]
[Times: user=0.04 sys=0.04, real=0.02 secs]


  1. 0.134: [GC pause (G1 Evacuation Pause) (young), 0.0144119 secs] – 只清理年轻代所属区的 G1 暂停暂停在 JVM 启动后 0.134 毫秒开始,耗时挂钟时间 0.0144119 秒。
  2. [Parallel Time: 13.9 ms, GC Workers: 8] – 以下的活动由 8 个工作线程并行执行,耗时挂钟时间 13.9毫秒。
  3. – 为了简单,省去了此部分。详情见后文。
  4. [Code Root Fixup: 0.0 ms] – 清理用来管理并行活动的数据结构,耗时应该总是接近 0 的,串行执行。
  5. [Code Root Purge: 0.0 ms] – 清理更多的数据结构,也应该很快,但耗时不总是接近 0,串行执行。
  6. [Other: 0.4 ms] – 其他乱七八糟的活动,很多是并行执行的。
  7. – 详情看后文。
  8. [Eden: 24.0M(24.0M)->0.0B(13.0M) – GC 前后伊甸区的使用量和容量。
  9. Survivors: 0.0B->3072.0K – GC 前后存活区所属的区块使用量。
  10. Heap: 24.0M(256.0M)->21.9M(256.0M)] – GC 前后堆的使用量和容量。
  11. [Times: user=0.04 sys=0.04, real=0.02 secs] – 分类统计的垃圾收集时长:
    • user:垃圾收集中的线程占用的 CPU 总时间
    • sys:系统调用和等待系统事件占用的 CPU 时间
    • real:应用暂停时长。GC 是并行执行的,,本次 GC 中使用了 8 个线程。要注意,GC 中总有一些操作是不能并行执行的,因此,实际的real值一般会比计算出来的值大一些。

几个专用的线程执行了大部分的繁重任务,它们的活动看下面的日志描述:

[Parallel Time: 13.9 ms, GC Workers: 8]
[GC Worker Start (ms) : Min: 134.0, Avg: 134.1, Max: 134.1, Diff: 0.1]
[Ext Root Scanning (ms) : Min: 0.1, Avg: 0.2, Max: 0.3, Diff: 0.2, Sum: 1.2]
[Update RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Processed Buffers: Min: 0, Avg: 0.0, Max: 0, Diff: 0, Sum: 0]
[Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Code Root Scanning (ms) : Min: 0.0, Avg: 0.0, Max: 0.2, Diff: 0.2, Sum: 0.2]
[Object Copy (ms) : Min: 10.8, Avg: 12.1, Max: 12.6, Diff: 1.9, Sum: 96.5]
[Termination (ms) : Min: 0.8, Avg: 1.5, Max: 2.8, Diff: 1.9, Sum: 12.2]
[Termination Attempts : Min: 173, Avg: 293.2, Max: 362, Diff: 189, Sum: 2346]
[GC Worker Other (ms) : Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
[GC Worker Total (ms) : Min: 13.7, Avg: 13.8, Max: 13.8, Diff: 0.1, Sum: 110.2]
[GC Worker End (ms) : Min: 147.8, Avg: 147.8, Max: 147.8, Diff: 0.0]


  1. [Parallel Time: 13.9 ms, GC Workers: 8] – 以下的活动由 8 个工作线程并行执行,耗时挂钟时间 13.9 毫秒。
  2. [GC Worker Start (ms) – 各线程启动活动的时间,跟暂停的时间是一致的。如果MinMax相差太多,说明 GC 线程太多,或者机器上有其他进程在抢占 JVM 的 CPU 执行时间。
  3. [Ext Root Scanning (ms) – 扫描外部(非堆)根对象花费的时间,比如类加载器、JNI 引用、JVM 系统根对象等等。除了Sum,其他都为挂钟时间。
  4. [Code Root Scanning (ms) – 扫描应用代码中的根对象花费的时间,比如局部变量等等。
  5. [Object Copy (ms) – 从CSet复制存活对象花费的时间。
  6. [Termination (ms) – 工作线程为了确保它们可以安全结束,且工作都已完成,然后退出执行所花费的时间。
  7. [Termination Attempts – 工作线程尝试退出执行的次数。尝试退出失败是指,如果工作线程发现实际上还有工作没有做完,退出失败。
  8. [GC Worker Other (ms) – 其他乱七八在的小活动,不值一提。
  9. [GC Worker Total (ms) – 工作线程总耗时。
  10. [GC Worker End (ms) – 工作线程完成工作的时间。一般它们应该差不太多,否则说明 GC 线程太多,或者机器上有其他进程在抢占 JVM 的 CPU 执行时间。

另外,此阶段还有些乱七八糟的处理活动,下面只提了其中一部分,其他的见后文。

[Other: 0.4 ms]
[Choose CSet: 0.0 ms]
[Ref Proc: 0.2 ms]
[Ref Enq: 0.0 ms]
[Redirty Cards: 0.1 ms]
[Humongous Register: 0.0 ms]
[Humongous Reclaim: 0.0 ms]
[Free CSet: 0.0 ms]


  1. [Other: 0.4 ms] – 其他小活动耗时,大部分也是并行执行的。
  2. [Ref Proc: 0.2 ms] – 处理非强引用耗时:清理或不清理它们。
  3. [Ref Enq: 0.0 ms] – 把剩余非强引用加入合适的ReferenceQueue的耗时。
  4. [Free CSet: 0.0 ms] – 从CSet中返还空闲区域耗时。返还的空闲区域可重新为对象分配空间。

并发标记(Concurrent Marking)

G1 算法建立在很多 CMS 的概念之上,所以先了解下 CMS 算法 是个不错的主意。虽然它与 CMS 有很多不同,但并发标记的目标是相似的。G1并发标记使用初期快照的方式标记在标记阶段开始时的存活对象–即使它们在标记时已不再存活。基于这些信息,CMS 为每个区域建立存活对象统计,这样就能够在将来更高效地选择CSet了。

这些信息可以用来帮助接下来的老年代垃圾搜集过程。在两种情况下是完全地并发执行的:一种是如果标记时能确定某些区中全是垃圾时;一种是在处理同时包含垃圾和存活对象的老年区的应用暂停期间,。

当堆使用率达到一定数值时,就会触发并发标记。默认值为 45%, 但也可以通过 JVM 参数来设置。和 CMS一样, G1 的并发标记也是由多个阶段组成,其中一些是完全并发的,还有一些阶段需要暂停应用线程。

阶段一:初始标记(Initial Mark)

本阶段标记所有从GC 根对象的直接可达对象。在 CMS 算法中,初始标记需要暂停应用,但 G1 通常在年轻模式 GC 时捎带执行本阶段,因此,开销非常小。你会看到,在年轻模式的首行 GC 日志中多了(initial-mark)标识:

1.631: [GC pause (G1 Evacuation Pause) (young) (initial-mark), 0.0062656 secs]

阶段二:根区扫描(Root Region Scan)

本阶段标记从根区可达的所有存活对象,所谓根区,就是那些在并发标记过程中必须执行 GC 的非空区域。因为在并发标记的同时移动对象会造成很多麻烦,因此本阶段必须在下一个年轻模式暂停到来前结束。如果年轻模式 GC 必须要提前开始,它会请求提前终止根区扫描,并待之结束。在当前实现中,根区就是所有的存活区,它们是年轻代中的一小部分,在一下个年轻模式 GC 到来时时肯定执行垃圾收集。

1.362: [GC concurrent-root-region-scan-start]
1.364: [GC concurrent-root-region-scan-end, 0.0028513 secs]

阶段三:并发标记(Concurrent Mark)

本阶段和 CMS 的相应阶段类似,简单的遍历对象图,在一个特殊的位图中标记访问过的对象。为了确保初期快照的语义被满足,G1 要求,应用在对象图上的所有并发更新必须为标记目的保留先前的引用。这是通过使用写前屏障(不要与后文的写后屏障和并发编程时的内存屏障搞混了)实现的。写前屏障的功能是,在并发标记过程中,当应用线程要修改某个字段引用时,把原先的引用存储到所谓的日志缓冲区,并发标记线程会处理这个缓冲区中的数据。

1.364: [GC concurrent-mark-start]
1.645: [GC concurrent-mark-end, 0.2803470 secs]

阶段四:重新标记(Remark)

本阶段会暂停应用,最终完成存活对象标记工作。G1 需要短暂的暂停应用线程,停止往日志缓冲区中写入引用更新日志,然后处理缓冲区中现有的日志,标记仍未标记的那些在并发标记启动时的存活的对象。本阶段还执行一些附带的清理工作,比如引用处理(见疏散暂停日志)或者卸载 Java 类。

1.645: [GC remark 1.645: [Finalize Marking, 0.0009461 secs] 1.646: [GC ref-proc, 0.0000417 secs] 1.646: [Unloading, 0.0011301 secs], 0.0074056 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]

阶段五:清理(Cleanup)

本阶段为下一个疏散阶段打基础,统计堆中区域的所有存活对象,并按照期望的 GC 效率排序这些区域。清理阶段还要处理所有的为下一个并发标记周期维护内部状态所必须的内务工作。

最后,但同样重要的,本阶段会回收哪些没有存活对象的区域。

本阶段的部分工作可以和应用并发执行,比如回收空闲区域,大部分的存活对象统计工作,但它也需要一个短暂的应用暂停来完成其他所有任务而不受应用线程的影响。暂停的日志类似下面这样的:

1.652: [GC cleanup 1213M->1213M(1885M), 0.0030492 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]

如果在堆中发现了一些全是垃圾的区域,日志会有点不同:

1.872: [GC cleanup 1357M->173M(1996M), 0.0015664 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
1.874: [GC concurrent-cleanup-start]
1.876: [GC concurrent-cleanup-end, 0.0014846 secs]


疏散暂停:混合模式(Evacuation Pause: Mixed)

并发标记清理阶段能够整块整块地释放老年区是最理想的情形,但现实很残酷。并发标记完成之后,G1 会安排一次混合模式的 GC,不只清理年轻代,还将清理部分老年区

并发标记完成之后,并不一定会立即进行混合模式的 GC。有很多规则和启发式算法会影响混合模式的启动时机,比如,在老年代中,如果可以并发地回收大量的老年区(上文中的阶段五),那么也就没有必要开启混合模式了。

因此,在并发标记混合模式之间,很可能会出现多次的年轻模式

添加到CSet老年区的具体数目及顺序也同样受到很多规则的约束,包括应用指定的软实时性能指标、老年区的活跃度、并发标记时 GC 的表现数据,还有一些可配置的 JVM 选项等等。混合模式的 GC 大部分过程和前面的年轻模式是一样的,但这里我们还要引入一个概念:RSet

RSet让 G1 在不同的区域上可以独立的进行 GC。例如,在 GC 区域 A、B、C 时,我们必须要知道是否有其区域中的对象持有指向其中的引用, 以确定对象的存活状态。但是遍历整个堆很费时,也违背了增量 GC 的初衷,因此必须采取某种优化手段。类似有些 GC 算法的卡片标记中使用Card Table来支持对年轻代进行独立垃圾收集,G1 中使用的是RSet

如下图所示, 每个区域都有一个RSet,包含了从外部指向本区的所有引用。这些引用将被视为附加的GC 根对象。要注意,在并发标记过程中,老年代中被确定为垃圾的对象会被忽略,即使有外部引用指向他们:此时引用对象自身也是垃圾。

g1-03-591x187

接下来和其他垃圾收集器一样,多个 GC 线程并行地找出哪些是存活对象,哪些是垃圾对象:

g1-04-591x187

最后, 存活对象被移动到存活区, 在必要时会为存活区加入新的区域。然后空闲区域被释放,可以再次存放新的对象。

g1-05-v2-591x187

在应用运行的过程中,为了维护RSet,只要应用线程更新某个字段,就会产生一个写后屏障。如果更新后的引用是跨区域的,就会在目标区的RSet中增加一个对应的卡片。为了降低写后屏障的开销,使用异步的方式将卡片放入RSet,并且做了很多优化。但基本流程如下: 写后屏障把脏卡信息存放到本地缓冲区,由专门的 GC 线程负责将之传递给目标区的RSet

混合模式下的日志,和年轻模式相比,可以发现一些有趣的地方:

[Update RS (ms) : Min: 0.7, Avg: 0.8, Max: 0.9, Diff: 0.2, Sum: 6.1]
[Processed Buffers : Min: 0, Avg: 2.2, Max: 5, Diff: 5, Sum: 18]
[Scan RS (ms): Min: 0.0, Avg: 0.1, Max: 0.2, Diff: 0.2, Sum: 0.8]
[Clear CT: 0.2 ms]
[Redirty Cards: 0.1 ms]


  1. Update RS (ms) – 因为RSet是并发处理的,要确保在实际的垃圾收集之前,必须先处理完缓冲区中的卡片。如果卡片数量很多,则 GC 并发线程可能因为负载太高而处理不完。这种情况可能由于修改的字段过多,或者 CPU 资源不足而导致的。
  2. Processed Buffers – 每个工作线程处理本地缓冲区的数目。
  3. Scan RS (ms) – 扫描RSet中的引用花费的时间。
  4. Clear CT: 0.2 ms – 清理卡片脏状态花费时间。只是简单的修改状态。
  5. Redirty Cards: 0.1 ms – 标记卡表适当位置状态为脏花费的时间。合适的位置是由 GC 导致的堆内变动所决定的,比如,GC 时把引用加入引用队列。

原文地址:GC Algorithms: Garbage First


相关文章