Back
Featured image of post JVM 学习笔记 07 - 经典垃圾收集器

JVM 学习笔记 07 - 经典垃圾收集器

JVM 学习笔记,本节讲解从 Serial 到 G1 的经典垃圾收集器。

上篇文章我们讲解了 HotSpot VM 的技术细节,本文将介绍一系列经典垃圾收集器。

此处所说「经典」,是指在 JDK 11 之前的 GC。与 JDK 17 中稳定的,具有革命性改进的高性能低延迟 GC,如 ZGC、Shenandoah GC 等区分。

多数 GC 的老年代和新生代可以分别指定。比如 ParNew + CMS 组合。然而 GC 搭配的可行性根据版本变化太多,此处就不赘述了,常用的组合会在后续提及。

Client / Server

JDK 中存在 -client-server 模式的区别。可以在 VM 参数中添加选择对应模式。

java -version 同样会显示 JVM 默认选择的模式:

❯ java -version 
openjdk version "17.0.3" 2022-04-19 LTS
OpenJDK Runtime Environment Zulu17.34+19-CA (build 17.0.3+7-LTS)
OpenJDK 64-Bit Server VM Zulu17.34+19-CA (build 17.0.3+7-LTS, mixed mode, sharing)

比如笔者此处默认就为 Server VM 模式。

Serial GC

Serial GC 是最基础的 GC。甚至是 JDK 1.3 之前的唯一选择。它是单线程的,使用单处理器或线程完成所有 GC。并且会在 GC 时停止用户线程,直到 GC 结束,也就是「Stop The World」。

对于新生代,它使用标记-复制算法;对于老年代,使用标记-整理算法。

然而 GC 实在是一门繁复且困难的工作,试想一下,打扫卫生时,竟然有人在地板上蹦跳,乱扔垃圾,想要打扫干净,势必是很不容易的。JVM 开发一直以来的重心就是 GC,从诞生之出就一直探索优化 GC 性能,直到今天也未曾改变。

ParNew GC

ParNew GC 可以看作是 Serial GC 的多线程升级版。对于新生代,GC 是多线程并行收集的(用户线程仍然被暂停)。其他所有行为都和 Serial GC 一致。

直至 G1 出现,在很长一段时间里,ParNew GC 都和 CMS GC 组合使用。

从 JDK 9 开始,JVM 还移除了 ParNew-Serial Old 和 Serial-CMS 两种组合。也就意味着,ParNew-CMS 是唯一的选择。

Parallel Scavenge GC

Parallel Scavenge GC 也是一款新生代 GC,也基于标记-复制算法。和 ParNew 的主要区别是:它主要关注吞吐量\[ \text{吞吐量}=\frac{\text{用户代码时间}}{\text{用户代码时间}+\text{GC 时间}} \] 高吞吐能够提高程序运行的整体效率(算得更多、更快),而降低延迟主要是为了提高用户体验。

Parallel Scavenge GC 提供了两个参数精准控制吞吐量:

  • -XX:MaxGCPauseMillis : 最大 GC 暂停时间,要求为大于 0 的毫秒数。收集器会尽力将收集时间降低到该值。该值越高,吞吐量会越低。

  • -XX:GCTimeRatio: 垃圾收集时间占比因子,要求为大于 0 小于 100 之间的整数。默认为 99,即允许最多 1% 的时间在 GC。 \[ 0 \lt n \lt 100,\ \text{Ratio}=\frac{1}{1+n} \] Parallel Scavenge GC 是吞吐量优先的 GC,这点不难看出。

    还有一个参数为 -XX:+UseAdaptiveSizePolicy 即自适应策略。无需人工手动指定新生代大小(-Xmn)、Eden、Survivor 区域比例(-XX:SurvivorRatio)、晋升老年代大小(-XX:PretenureSizeThreshold)等细节参数。VM 会根据当前系统状态自动选择,也就是自适应调节策略(GC Ergonomics)。

Serial Old GC

Serial 收集器的老年代版本。单线程,使用标记-收集。主要供 client 模式使用;在 server 下一般作为出错时的后备。

Parallel Old GC

Parallel Scavenge 的老年代版本。基于标记-整理算法,直到 JDK 6 才开始提供。在此之前 PS GC 其实一直比较尴尬,因为只能搭配 Serial Old,导致性能不佳。吞吐量甚至还不如 ParNew + CMS。

Parallel Old 出现之后,「吞吐量优先」的场景才有了合适的 GC 组合,即使用 Parallel Scavenge 和 Parallel Old。

CMS GC

Concurrent Mark Sweep GC 是一种以短延迟为目标的收集器。JVM 通常用于为用户提供服务,所以对延迟十分敏感,以带来最好的交互体验。CMS 就非常适合这种需求。

从名字就可以看出来它基于标记-清除算法。相比前面几种,CMS 的运行过程比较复杂,分为四个阶段:

  1. 初始标记(CMS initial mark),多线程,需要 STW。即标记 GC Roots 的直接关联对象,速度很快。
  2. 并发标记(CMS concurrent mark),多线程,不需要 STW。基于初始标记的结果,并发遍历整个对象图。虽然耗时较长,但是可以和用户线程并行。
  3. 重新标记(CMS remark),多线程,需要 STW。为了修复并发标记时变动的对象,使用之前提到过的增量更新法重新标记对象图。一般比初始标记耗时,但远比不上并发标记的耗时。
  4. 并发清除(CMS concurrent sweep),多线程,不需要 STW。由于使用标记-清除,不需要移动对象,所以可以并发。

总体而言,CMS 的设计很优秀,是 HotSpot VM 的第一次成功尝试。但远远没有达到圆满,至少有以下三个缺点:

  • 对 CPU 资源敏感。它虽然不会导致用户线程停顿,但却会因为占用了一部分线程,所以会降低总吞吐量。默认启动的回收线程数量为 \(\frac{\text{CPU}_n+3}{4}\) 也就是 CPU 核心多于 4 个时,只会占用最多 \(25\%\) 的 CPU,并且随 CPU 核数增加而下降。但若 CPU 不足 4 核,CMS 对用户程序的影响就很大。造成用户程序的执行速度忽然骤降。
  • CMS 无法处理浮动垃圾,很可能出现 Concurrent Mode Faliure 而导致 STW 的 Full GC。之前说过,并发标记和并发收集阶段,用户程序还在运行,只能余留空间给可能的新对象(这部分称为浮动垃圾),并等待下一次 GC。所以无法等待老年代完全填满了再收集。默认为到达了 \(68\%\) 的阈值,就会激活(在 JDK 6 时被提高到 \(92\%\))。可以通过 -XX:CMSInitiatingOccu-pancyFraction 调整,适当调高可以降低 GC 频率,获得更好的性能。然而这也会出现新的风险:要是内存无法满足新对象的需要,又会出现一次 Concurrent Mode Failure,JVM 不得不启用后备方案,冻结用户线程并使用 Serial Old 重新 Full GC。
  • CMS 基于标记-清除算法,因此无法避免内存碎片化。往往会出现大对象无法分配,被迫 Full GC 的情况。在 JDK 9 之前会默认开启(之后被弃用了) -XX:+UseCMS-CompactAtFullCollection 参数,在 Full GC 时启用内存整理。这样虽然内存碎片化问题解决了,但是停顿时间又会变长。因此 JVM 又给了另一个参数 -XX:CMSFullGCsBefore-Compaction,即执行多少次 Full GC 后,才执行一次有空间整理的 Full GC。(默认为 0,表示每次都会进行碎片整理)

Garbage First GC

Garbage First GC (简称 G1),是 GC 里程碑式的成果。在 JDK 9 中被稳定,成为默认值。甚至使用 CMS 都提示被废弃。

同时,JDK 内部也进行了一次重写,GC 的接口和实现分离,更容易维护和加入新 GC。

G1 设计者希望能建立一套支持「停顿时间模型」(Pause Prediction Model)的 GC,支持毫秒级的时间控制。

G1 的分代收集,跳脱出了要么收集新生代(Minor GC),要么收集老年代(Major GC),要么就是整个Heap(Full GC)的定势。转而可以面向堆的任意部分来组成回收集(Collection Set, CSet)。度量标准不再是属于哪个分代,而是谁的回收收益更大,这就是 G1 的 Mixed GC 模式。

G1 的 Region 堆布局是实现这个目标的关键。将 Heap 分为多个大小相等的独立区域,每一个区域都可以根据需要,称为 Eden、Suvivor、老年代。还有一类特殊的 Humongous Region 用于存储大对象。Region 大小可为 1 到 32 MB,且为 2 的 N 次幂,可通过 -XX:G1HeapRegionSize 设定。超过 Region 大小的对象会被放入 Humongous Region 特殊对待。

虽然 G1 仍然有新生代和老年代概念,但区域不再固定。Region 成为了最小单元,能够有效避免 Full GC 问题。更详细一点,G1 会跟踪各个 Region 里 GC 的「回收价值」维护列表,每次在用户规定的时间内(通过 -XX:MaxGCPauseMillis 指定),将名列前茅的 Region 优先收集。这也是 Garbage First 这个名字的又来。

G1 的总体思路并不复杂,然而探明细节却很困难。不然也不会从 2004 年 Sun 实现室发布第一篇关于 G1 论文到 2012 年 4 月 JDK7u4 才倒腾出能商用的 G1 收集器来。以下问题是需要妥善解决的:

  • 如何解决跨 Region 对象引用的问题?

    基本思路很简单,可以使用记忆集避免全堆 GC Roots 扫描。

    但 G1 的实际应用很复杂。每个 Region 都有自己的记忆集,会记录下别的 Region 指向自己的指针,并标记这些指针对应的卡表维护范围。本质上是一个类似 HashMap<RegionStartAddress, List<CardIndex>> 的结构,是一种双向的卡表结构,不仅仅记录了「我指向谁」,还记录了「谁指向我」,维护起来的成本相当之高。G1 经常会花费 \(10\%\)\(20\%\) 的堆维护 GC 工作。

  • 并发标记阶段,如何保证用户线程和收集线程互不干扰?

    这里首先要保证对象图稳定问题,G1 是通过 SATB(之前提到过的原始快照)算法来实现的。

    此外,GC 对用户线程的影响还体现在 GC 途中新对象的创建,程序只要在运行,一定是会有新对象创建的。G1 为每一个 Region 设置了两个 TAMS(Top at Mark Start)指针,将 Region 划出一部分用于新对象,同时它们也被隐式标记,默认存活,不纳入回收范围。

    与 CMS 中的 Concurrent Mode Faliure 类似,G1 也会因回收速度赶不上分配速度,而出现 Full GC 从而 STW 的情况。

  • 怎么建立起可靠的停顿预测模型?如何逼近 -XX:MaxGCPauseMillis 指定的停顿时间?

    G1 的停顿预测以衰减均值(Decaying Average)为理论基础实现,GC 途中,G1 会记录每个 Region 的回收好事、Region 记忆集的脏卡数量等可观测的数值,并分析出平均、标准差、置信等统计信息。

    衰减均值的意思是,比普通的平均值更容易受新数据影响(新数据权重更高)。然后排序这些统计信息,将名列前茅者优先收集。

G1 GC 步骤大致可以分为 4 步:

  1. 初始标记(Initial Marking):标记 GC Roots 的直接引用,修改 TAMS 指针,让新对象能够正确分配。需要停顿用户线程,但耗时短,且和 Minor GC 同步完成,几乎没有停顿。
  2. 并发标记(Concurrent Marking):以前一步为基础,进行可达性分析。耗时较长,但和用户线程并发运行。
  3. 最终标记(Final Marking):短暂暂停,用于处理遗留的少量 SATB 记录。
  4. 筛选回收(Live Data Counting and Evacuation):更新 Region 统计数据,对回收价值排序,根据用户指定的参数制定回收计划。将多个 Region 构成回收集,清理并移动对象。

同样的,指定的参数 -XX:MaxGCPauseMillis 不应过短,否则会导致垃圾堆积,最终 Full GC,STW 的情况。

在 JDK 8、JDK 11,G1 相比 CMS 可能还有优劣之分。如今 JDK 17 时代,CMS 甚至已经被彻底删除。就不必再多考虑了。

然而 G1 确实是存在一些问题的,比如:

  • 筛选回收阶段,仍然需要暂停用户线程。

这需要后面低延迟垃圾收集器如 ZGC、Shenandoah GC 的继续努力,下篇文章的主题就是它们。

comments powered by Disqus