通俗易懂 JVM 中的 GC 实现

1,971 阅读9分钟

在上篇文章中介绍了Java GC:基础原理,这篇文章来看下在 JVM 中是如何实现具体的收集器的。

JVM 提供了多种垃圾收集器用来分别收集新生代和老年代,新老收集器之间可以组合使用,但是在实际使用中(基于Java 8),一般常见的有4种收集器组合

  • Serial GC 收集新生代和老年代 -XX:+UseSerialGC
  • Parallel GC 收集新生代和老年代(JVM默认使用的组合) -XX:+UseParallelGC(该参数包含了-XX:+UseParallelOldGC)
  • Parallel New 收集新生代 + Concurrent Mark and Sweep (CMS) 收集老年代 -XX:+UseConcMarkSweepGC(该参数包含了 -XX:+UseParNewGC)
  • G1 (不严格区分新老代)-XX:+UseG1GC-

PS: 在 Java 8 中 -XX:+UseParallelOldGC 和 -XX:+UseParNewGC 已经无法单独使用

下面我们从原理上去理解每个收集器是如何工作的,但并不会去细究具体的实现。

Serial GC

Serial GC 使用 mark-copy 算法处理新生代,使用 mark-sweep-compact 算法处理老年代。正如名字所说的那样,这是一个单线程的收集器。整个收集过程会触发 stop-the-world 暂停应用,直到回收结束。该收集器适用于只有几百M大小的堆和单核 CPU的情况。对于服务端,很少会使用这个组合,因为无法合理的使用计算机资源,不过反过来说的话,这也可以满足对系统资源使用有限制的情况。

Parallel GC

Parallel GC 和 Serial GC 使用的算法一样。只不过是多线程的,是 JVM 默认使用的 GC 。和 Serial GC 一样,在整个收集过程会触发 stop-the-world 暂停应用,直到回收结束。在多核环境下,处理速度要比 Serial GC 快,可以提高吞吐量。

Concurrent Mark and Sweep(CMS)

Serial GC 和 Parallel GC 在回收垃圾的过程中需要暂停应用线程,因此如果对应用延迟有要求的话,CMS 收集器是一个更好的选择。其使用 stop-the-world 的 mark-copy 算法收集新生代(ParNew,和 Parallel GC 相似但不兼容,只和 CMS 一起使用),使用几乎和应用线程并发的 mark-sweep 算法收集老年代。

PS :至于 ParNew 和 Parallel 的关系是有历史原因的,有兴趣的可以看这个为什么有Parallel GC 了,还要有个 ParNew,还不兼容?

CMS 主要用于避免老年代回收时长时间的暂停。首先,其使用了 mark-sweep 算法而不进行压缩(注意由于会产生内存碎片,因此在内存分配上使用了 free-lists 而不是指针碰撞,所以分配速度会相对慢一些) 。其次,在 mark-sweep 算法收集过程中,与应用程序几乎是并发的,几乎不影响应用程序的执行(暂停的时间很短)。

PS:CMS为什么没有采用标记-整理算法来实现?

不过凡事有利即有弊

  • 由于大多数时候 CMS 至少使用了一些 CPU 资源而没有执行应用程序的代码,因此 CMS 的吞吐量通常要比 Parallel GC 差。
  • 由于 sweep 后没有进行 compact,必然会造成内存碎片的问题(JVM 提供了参数可以设置在一次或多次 Full GC 后对内存进行整理)。
  • 由于存在和应用程序并发的阶段,不能和其他收集器一样,等到老年代放不下了才做回收,需要设定一个阈值。如果在并发过程中,老年代满了的话,则会触发 “Concurrent Mode Failure” 错误,说明并发过程失败,会 stop-the-world,执行 Full GC,等到回收完毕后才恢复应用线程。
  • 会存在浮动垃圾,即标记存活后死亡的对象,需要在下一次 GC 中才能清理。

CMS 的并发收集过程如下:

  1. 初始标记(Initial Mark) 初始标记是一个 stop-the-world 的过程,不过该过程只需要标记老年代中所有 GC Roots 可直接抵达的对象,时间非常短。
  2. 并发标记(Concurrent Mark) 在不暂停用户线程的情况下,并发的标记存活对象。此时由于应用线程还在运行,可能会有一些新的对象进入到老年代,或者之前的对象在标记后发生一些引用的变化。在之前介绍的基础算法中的内容提到过 JVM 使用了 Card Marking 的技术来标记老年代对新生代的引用;这里也使用了相同的技术,并发过程中产生的引用变化也使用了一个和 CardTable 类似的结构(ModUnionTable)记录下引用变化的对象。
  3. 预清理阶段 (Concurrent Preclean) 这个阶段需要处理上个阶段遗留的问题,遍历 Ditry Card 中对象重新标记一遍存活对象。不过,对于之前标记存活但现在已经死亡的垃圾对象就需要留到下一次清理了(浮动垃圾)。这个阶段是可以关闭的。
  4. 可中断预清理阶段 (Concurrent Abortable Preclean) 重复执行上一个阶段的操作,直到满足某种条件,可以是循环次数达到阈值或者循环处理时间达到阈值等等。该阶段的目的在于尽可能的去处理那些在并发阶段被应用线程更新的老年代对象,以减少重新标记阶段的暂停时间。
  5. 最终重新标记 (Final Remark). 只要还在并发,就永远存在新的引用关系没有处理,因此需要整个过程中第二个也是最后一个 stop-the-world 的阶段来确保所有存活的对象都被标记。
  6. 并发清除 (Concurrent Sweep) 由于不需要 compact,没有 stop-the-world 的必要,直接清除就好了。CMS会维护自己的空闲列表,不会把在这个阶段中新晋升上来的对象给清除了。
  7. 并发重置 (Concurrent Reset) 重置内部的数据结构以便下一次使用

此外,除了被动的由 Mionr GC触发 Old GC 外,在实际应用中,还存在着主动的,周期性的 Old GC,只不过存在着触发条件,有兴趣的可以自行额外查阅。

虽然 JDK 9 已经废除 CMS 垃圾收集器,但 CMS 仍然是 JDK 8 及以前的追求低延迟的不错的可选项 。

G1 - Garbage First

为解决 CMS 算法产生的一系列问题缺陷,HotSpot提供了另外一种垃圾回收策略,从 JDK 9 开始,G1 取代 Parallel GC 成为 JVM 中的默认 GC。G1 的其中一个核心目标是使 stop-the-world 的时间可预测并且可配置。比如说,你可以要求每分钟 stop-the-world 的暂停时间不能超过 5 毫秒,G1 会尽最大可能去完成需求,但并不是一定能达到的。

不像之前的堆内存是按分代连续分布的,G1 将堆划分成了一定数量(典型的如2048个)的小区域(Region),每个小区域可能是 Eden, Survior 或者 Old 区域。在逻辑上,Eden 和 Survivor 区域组成了新生代,Old 区域组成了老年代。这个设计允许 GC 每个只收集其中的一些区域而不是整个堆。不过新生代区域每次都会参与。另一个比较新奇的特点是 G1 可以估计每个区域存活对象的多少,存活数量多的区域会先被收集,这也是这个收集器名字的由来,garbage first 收集器。

G1 大致可分为 Young GC 和 Mixed GC,Young GC 处理所有的 Young Region, Mixed GC 处理所有的 Young Region 和 部分的 Old Region,至于选择哪些 Old Region,需要并发标记来搜集必要的信息。分代式G1的正常工作流程就是在 young GC 与 mixed GC 之间视情况切换,背后定期做并发标记搜集资料。

Young GC —— 只回收年轻代的疏散阶段 (Evacuation Pause: Fully Young)

在应用程序刚开始的时候,G1 没有额外的信息去运行并发阶段。因此在这个阶段,G1 的运作模式和其他的新生代收集器很像,需要 stop-the-world,使用复制算法,将存活对象的复制到 Survivor 区域中,或者空闲区域中(之后这个区域也是 Survivor 区)。该阶段可以理解为 Young GC (Minor GC),所谓疏散,其实就是 copy 到其他区域中。

并发标记 (Concurrent Marking)

G1 收集器的很多概念是建立在 CMS 上的,因此在流程上会有一些相似。尽管如此,它们之间也有很多不一样的地方,比如 CMS 则是采用增量更新的方式,即额外 mark 修改的引用再做处理,而 G1 使用了 SATB (Snapshot-At-The-Beginning) 来维持并发 GC 的正确性 。Snapshot-At-The-Beginning,从字面上理解,就是在 GC 开始时产生一个所有存活对象的逻辑快照,在这个快照中存活的对象,加上之后在 GC 过程中新分配的对象认为是最终的存活对象,其它不可到达的对象就是死的了。关于 SATB 具体是如何工作的,可以看R大写过的 G1 讲解

和 CMS 一样,该阶段具体的可以分为以下几个阶段,每个阶段的目的和 CMS 差不多,只是具体实现上会有区别:

  1. 初始标记(Initial Mark)
  2. 并发标记(concurrent marking)
  3. 最终标记(remark)
  4. 清理(cleanup)

其中 Initial Mark 在 Evacuation Pause 中捎带完成了。注意这里的 cleanup,并不是真的在堆上清理了实际对象,而是统计每个 region 存活数量的多少,并按预期的 GC 效率对它们进行排序,为 mixed GC 做准备。但有例外,如果发现这个 region 都没有存活对象,整个区域会被回收到可分配的 region 列表中。

Mixed GC —— 混合的疏散阶段 (Evacuation Pause: Mixed)

这个阶段并不一定紧跟在并发标记之后,需要满足一定的条件,比如,如果可以同时释放一大部分的老年代,否则该阶段没有必要。因此,在并发标记结束和混合疏散暂停之间可能很容易出现一些只回收年轻的疏散阶段。

该阶段选定所有新生代里的 region,外加根据并发标记统计得出收集收益高的若干个老年代 region(在用户指定的开销目标范围内尽可能选择收益高的old gen region)进行回收。