第三章 垃圾收集器与内存分配策略 | part 3

58 阅读10分钟

前言

本节介绍一些经典的垃圾收集器,不同的收集器会用在不同的分代上,如下图所示:

image.png

Serial/Serial Old 收集器

Serial 收集器是最基本、发展历史最悠久的收集器,它基于标记-复制算法,在JDK 1.3.1之前是虚拟机新生代收集的唯一选择, 这个收集器是一个单线程的收集器, 它的 “单线程” 的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,即 “Stop The World”,直到它收集结束。Serial 收集器简单高效,是 HotSpot 虚拟机客户端模式下的默认新生代收集器。

Serial Old 收集器是 Serial 的老年代版本,使用标记-整理的算法。它同样是单线程的收集器,主要也是让 HotSpot 虚拟机客户端模式使用,另外可以作为 CMS 收集器的失败备案

image.png

ParNew 收集器

ParNew 收集器实际上是 Serial 收集器的多线程并行版本,除了使用多条线程进行垃圾收集以外,其余都和 Serial 收集器完全一致。

另外,ParNew 收集器是除了 Serial 收集器外,唯一能和 CMS 收集器配合使用的。因此 JDk 5 之后,ParNew + CMS 是 HotSpot 虚拟机服务端模式下的推荐组合,直至 JDK 9 被 G1 取代。

image.png

Parallel Scavenge 无法和 CMS 配合有两个原因:

  1. 两者的目标一个是低延迟,一个是高吞吐量;
  2. Parallel Scavenge 未使用 HotSpot 中原本的分代框架。

Parallel Scavenge/Parallel Old 收集器

Parallel Scavenge 收集器也是一款基于标记-复制算法的新生代收集器。类似于 ParNew、CMS 等收集器的关注点都是尽可能缩短 STW 的时间,而这款收集器的目标则是达到一个可控的吞吐量。吞吐量是指:

吞吐量=运行用户代码时间运行用户代码时间+运行垃圾收集时间吞吐量={运行用户代码时间 \over 运行用户代码时间+运行垃圾收集时间}

停顿时间短适合用户交互的高响应程序;而高吞吐量能高效率利用处理器资源,适合后台运算密集型任务。

Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis 以及直接设置吞吐量大小的 -XX:GCTimeRatio。另外还有一个参数 -XX:+UseAdaptiveSizePolicy 值得关注,当开启这个参数时,虚拟机会自适应调节如新生代大小、晋升老年代大小等细节参数,这也是 Parallel Scavenge 收集器区别于 ParNew 收集器的一个重要特征。

Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本,使用多线程和标记-整理算法。在注重吞吐量以及 CPU 资源敏感的场合,可以优先考虑 Parallel Scavenge + Parallel Old 的组合。

image.png

CMS 收集器

CMS 收集器是一种以获取最短回收停顿时间为目标的收集器。像是互联网服务等关注响应速度的服务,会希望系统停顿时间尽可能短,以提供用户良好的交互体验,就适合用 CMS 收集器。

CMS 收集器是一种基于标记-清除算法的收集器,整个过程分为四个步骤:

  1. 初始标记:标记 GC Roots 直接关联到的老年代对象被新生代引用的老年代对象,速度很快,会发生 STW;
  2. 并发标记:从初始标记的结果开始遍历整个对象图,耗时长,但可以和用户线程并发执行;新生代晋升的对象新分配到老年代的对象以及在并发阶段被修改了的对象mod-union table 中的对应页标记为脏页。
  3. 重新标记:修正并发标记阶段由于用户线程继续运行而产生的变动(主要针对并发标记中的垃圾对象被重新引用的情况),会发生 STW;由于在并发阶段可能产生新的 GC Roots,因此要重新遍历整个 GC Roots 集合、mod-union table 中的脏页以及新生代对象
  4. 并发清除:清除掉标记出来的已死亡对象,可以和用户线程并发执行。

mod-union table 是一个类似于卡表的结构,记录并发标记阶段新产生的对象以及引用关系的变更。

image.png

虽然 CMS 是对于低停顿追求的一次成功尝试,但它仍然有几个明显的缺点:

  • 对 CPU 资源敏感:事实上这是并发设计的通病,当 CPU 核心数量不足时,分出一部分线程/计算能力去执行收集器工作会导致用户程序的执行速度大幅下降。
  • 无法处理浮动垃圾:由于并发标记阶段用户线程不断运行,随时会产生新的垃圾对象,这一部分对象被称为浮动垃圾,只能到下次垃圾收集时再清理掉。另外会产生浮动垃圾也就意味着必须预留一部分空间供并发标记时的程序运作使用,如果 CMS 期间预留的内存无法满足程序分配新对象的需要,就会出现 “并发失败”,这时虚拟机会启动预备方案:冻结用户线程,启用 Serial Old 收集器重新进行老年代垃圾收集。
  • 产生内存碎片:CMS 收集器是基于标记-清除算法的,这意味着可能产生很多内存碎片,当碎片过多,无法找到连续空间来分配给当前对象时,会触发一次 Full GC

针对漏标问题,CMS 采用增量更新解决,即通过后置写屏障,将发生引用变更的老年代卡页置为脏页,一并遍历。

G1 收集器

不同于以往的分代收集,G1 收集器开创了新的道路。它把整个 Java 堆区域分成了一个个 Region,每个 Region 在逻辑上会扮演 Eden 区Survivor 区或者老年代区。另外还有一种特殊的 Humongous 区域,专门用来存储大对象,即超过 Region 容量一半的对象。超过整个 Region 的对象会被存放到 N 个连续的 Humongous 区域中,如果找不到这样的区域则会发生 Full GC。

image.png

可预测停顿模型

G1 收集器的全名是 Garbage First,意思是它会根据允许的收集时间,优先选择回收价值最大的 Region,而允许的收集时间就是所谓的 “可预测停顿”。G1 收集器的停顿预测模型是以衰减平均为理论基础来实现的,在垃圾收集过程中,G1 收集器会记录每个 Region 的回收耗时、回收获得的空间大小等,并用衰减平均计算出 “最近的” 平均状态,然后在后台维护一个优先级列表,每次根据用户允许的停顿时间,优先处理价值最高的 Region。举个具体的例子,像在下图这样的状态下,收集器自然会回收时间短、价值大的二号区域,而非一号区域。

image.png

特殊的记忆集结构

在其他的收集器中,都有用到 “卡表” 这个结构。卡表是记忆集的一种实现形式,在 part 2 中我们介绍了记忆集/卡表的存在意义,即解决跨代引用的问题。在 CMS 中,只需要一个老年代的卡表,相当简单,但是在 G1 中,跨 Region 引用问题会更加的复杂,因为不同 Region 之间都是非连续的,因此也需要一种更复杂的结构来记录关系。事实上,在 G1 中,每个新生代 Region 都维护有自己的记忆集,这些记忆集在存储结构上是一个哈希表,key 是别的老年代 Region 的起始地址,value 是一个类似卡表的结构,表示 key 对应的 Region 的每个卡页是否有对本 Region 的引用。普通的卡表逻辑是 point out,而 G1 的卡表逻辑还包括了 point into,因此实现起来复杂很多。另外由于 Region 数量庞大, G1 收集器要比其他的传统垃圾收集器有着更高的内存负担(至少要 Java 堆容量 10% 至 20% 的额外内存)。

最后再配上一张记忆集的示意图,来更清晰的说明这种结构。

image.png

图中以 Region 1 的记忆集为例,由于 Region 2 的 1、4 号卡页和 Region 3 的 0、4 号卡页存在对 Region 1 中对象的引用,因此 Region 1 的 Rset 中 key 为 Region 2 起始地址的键值对对应的 value 就是 0101,其中 1 表示脏卡,Region 3 同理。

并发问题

和 CMS 类似,G1 也包含并发标记阶段,如何保证此阶段中收集线程和用户线程互不干扰呢?这里的干扰存在两种情况,下面分别来探讨一下。

  • 对象漏标问题

    漏标指的是在并发阶段灰色对象断开对白色对象引用的同时,黑色对象引用了该白色对象。这会导致不该被回收的对象变成了垃圾对象,影响程序的正常运行(详见补充:三色标记法)。针对这个问题,CMS 采用的是增量更新(相当于将节点重新标为灰色,有性能问题),而 G1 采用的是 STAB(Snapshot At The Beginning),即在一次 GC 开始之前产生一张 “快照”,之后的遍历标记过程按照这张快照进行。换句话来说,一开始认为是 “活” 的对象在整个 GC 过程中都认为是活的。事实上只需要利用前置写屏障把每次引用关系变化时旧的引用值记下来就好了。

  • 新分配对象问题

    在并发阶段新分配的对象都会被认为是活跃对象,那如何知道哪些对象是 GC 之后分配的呢?G1 为每个 Region 设计了两个名为 TAMS(Top At Mark Start),并发回收时新对象都必须分配在这两个指针之间。G1 默认在这个地址范围的对象是隐式标记过的,不纳入回收范围。与 CMS 收集器中内存回收速度赶不上内存分配速度会产生错误一样,G1 收集器也会冻结用户线程的执行,执行 Full GC 而产生长时间的 STW。

收集流程

  1. 初始标记:扫描 GC Roots 集合,标记所有从根集合可直接到达的对象,并修改 TAMS 的值。这个过程耗时短,但需要 STW

  2. 并发标记:进行可达性分析,递归扫描整个堆中的对象图。这个阶段耗时较长,但可与用户程序并行执行。扫描完成后,还要重新处理 SATB 所记录下的引用

  3. 最终/重新标记:利用短暂的 STW,处理在并发标记阶段剩余未处理的 SATB 记录。这个过程与 CMS 的重新标记有一个本质的区别,这个暂停只需要扫描 SATB buffer(将这些旧引用作为根重新扫描一遍,避免漏标),而 CMS 则需要重新扫描整个 GC Roots 集合以及并发阶段产生的增量。

  4. 筛选回收:对各个Region的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划,将决定回收的 Region 中的存活对象复制到空的 Region 中,再清理掉整个旧空间。由于涉及到存活对象的移动,会发生 STW

image.png

和 CMS 相比,G1 有着指定停顿时间、按收益动态收集、不会产生内部碎片等等优势,但内存占用和执行负载都很高。因此小内存应用可以使用 CMS,而大内存应用则使用 G1