0%

对 JVM 垃圾收集器的理解

前言

最近在研究 JVM 相关的知识点,对垃圾收集器有一些自己的看法,所以就记录下来。

垃圾收集器

定义

顾名思义,垃圾收集器就是清理程序运行过程中产生的垃圾,防止对象膨胀导致应用程序 OOM。

而垃圾指的是当前时刻下程序不再需要使用的对象,即不可达对象。

作用过程

根据定义,垃圾收集器过程分为两大步:识别垃圾(不可达对象),清理不可达对象。

识别不可达对象

不可达对象的定义:程序中其他对象对该对象的引用次数为 0。这衍生出两种识别方式:引用计数法,可达性分析。

引用计数法 可达性分析
作用原理 每个对象都维护一个计数器,其他对象有引用的话,计数器加一 对象的引用关系为树状结构,根节点为 JVM 指定的 GC Root。通过深度优先遍历搜索存活对象
优点 使用简单。无额外存储成本。计数器可维护在对象头上。 对象图清晰,不存在无法清理的垃圾
缺点 存在无法清理的垃圾。例子:两个互相引用的对象,没有被其他对象引用,实际上是垃圾,但是仍被识别成存活对象 使用比较复杂,需要额外内存空间存储对象关系

清理不可达对象

清理不可达对象主要有三种方式:标记 - 清除算法,复制算法,标记 - 整理算法。

标记 - 清除 复制 标记 - 整理
作用原理 直接清除不可达对象占用的内存空间 内存分为两块区域:当前区/空闲区。将存活对象复制到空闲区中,清理当前区 清除不可达对象占用的内存空间,移动存活对象到内存一端,移动分配指针到末尾
优点 实现简单 实现简单 不会产生内存碎片。不需对内存进行分区,内存利用率高
缺点 会造成内存碎片 内存利用率低。涉及内存仅有一半可用 实现比较复杂
适用场景 少量垃圾的清理 大量垃圾的清理 少量垃圾的清理

垃圾收集器种类

种类太多了,仅列以下几种:

  • 专注于老年代的 CMS
  • 局部收集的 G1
  • 低延迟的 Shenandoah、ZGC

垃圾收集器的发展历程

分代回收的 JVM

在 G1 出现之前,JVM 内存区域主要分为两部份:新生代/老年代。新生代的收集器大多采用复制算法,老年代的则是采用标记 - 清除算法。

老年代的 CMS 收集器作用过程如下

  1. 初始标记(STW 确定 GC Roots)
  2. 并发标记(与用户线程并行扫描对象图,三色标记法采用增量更新法)
  3. 重新标记(STW 最终确定对象图)
  4. 并发清除(与用户线程并行,采用标记 - 清除算法清除所有不可达对象)

而这样的内存分配与回收过程我认为存在几个缺点:

  1. 内存分配不合理。区分新生代/老年代,导致总体利用率不高。
  2. 新生代内存利用率低。虽然依据对象朝生夕灭的特性采用复制算法,提升垃圾回收的效率,但是降低了新生代整体的内存利用率。
  3. 老年代存在空间碎片。虽然依据对象存活几率大的特性采用标记 - 清除算法,提升垃圾回收的效率,但是产生的内存碎片后续只能通过 FULL GC 解决。
  4. 回收时间不可控。在 CMS 并发清除过程中清除了所有不可达对象,时间未知。且回收线程与用户线程并行,也会占用一部份系统资源。

可预测回收时间的 G1

G1 部分解决了上面说的缺点

  1. 针对内存分配不合理。G1 将内存分为普通 Region/Humongous region,宏观上没有连续的新生代/老年代。
  2. 针对新生代利用率低。G1 收集垃圾微观上采用复制算法,宏观上采用标记 - 整理算法,辅以 Region 的内存布局,无需大的空闲区。
  3. 针对空间碎片。从微观/宏观的垃圾收集算法都避免了内存碎片的产生。
  4. 针对回收时间不可控。G1 不再回收所有对象,而是在设定的回收时间内回收高价值的 Region。

G1 的作用过程如下

  1. 初始标记(STW 确定 GC Roots)

  2. 并发标记(与用户线程并行扫描对象图,三色标记法采用原始快照法)

  3. 最终标记(STW 最终确定对象图)

  4. 筛选回收(微观上采用复制算法,宏观上采用标记 - 整理算法在指定时间内回收的高价值 Region)

两个关键问题

  • G1 为什么三色标记法的解决采用原始快照法

对象关系采用记忆集维护减少了对象扫描时间,同时减少浮动垃圾带来的筛选回收成本。

三色标记法文章中,对比了增量更新与原始快照的优点缺点:

  1. 增量更新的优点是扫描范围小,缺点是会产生浮动垃圾。

  2. 原始更新的优点是不会产生浮动垃圾,缺点是扫描了整个对象图。

G1 是一个基于 Region 的收集器,使用记忆集管理对象关系,减少了最终标记的时间。同时微观上采用复制算法,浮动垃圾的产生会增加复制工作量。所以采用原始快照的方式。

  • 为什么筛选回收时微观上采用复制算法

复制存活对象的范围变小。G1 的复制是基于 Region 的,Region 所占整体内存比例较小,有效避免了复制算法的缺点。

G1 仍存在的问题

  1. 对象堆越大停顿时间越长。G1 需要 STW 进行筛选回收,主要涉及存活对象移动与记忆集指针修改。回收的对象越多停顿时间越长,不然只能等 FULL GC。
  2. 极端情况下需要一半的 Region 作为空闲区。虽然对象朝生夕灭,但极端情况下所有对象都存活就激发了复制算法的弊端。

低延迟的 Shenandoah

作用过程

  1. 初始标记(STW 确定 GC Roots)

  2. 并发标记(与用户线程并行扫描对象图,三色标记法采用原始快照法)

  3. 最终标记(STW 最终确定对象图)

  4. 筛选回收

    1. 并发清理
    2. 并发回收
    3. 初始引用更新
    4. 并发引用更新
    5. 最终引用更新
    6. 并发清理

G1 存在问题的解法

针对 G1 存在的问题 1: 对象堆越大停顿时间越长,Shenandoah 给出了它的解法:二八定律。

Shenandoah 将需回收的 Region 分为两部分:无存活对象的 Region(大多数),存在存活对象的 Region(少数)。

  • 无存活对象的 Region,不需要有其他记忆集的修改,在并发清理阶段直接清理。
  • 存在存活对象的 Region,在并发回收阶段复制对象后维护一个转发指针,在并发引用更新与最终引用更新再修正引用。

低延迟的体现

将 STW 复制清理对象简便为 STW 更新对象指针,使应用程序感知停顿时间短。

Shenandoah 仍存在的问题

  1. 极端情况下需要一半的 Region 作为空闲区。因为 Shenandoah 在 4.2 中复制了所有存活对象到新 Region,仍存在与 G1 一样的问题。
  2. 转发指针降低了对象访问的性能。Shenandoah 在 4.2 建立了转发指针,每次访问存活对象都需要通过读屏障,降低了访问性能。

低延迟的 ZGC

作用过程

  1. 并发标记。基于染色指针存储对象图信息。
  2. 并发预备重分配。选择待复制的存活对象所在的 Region 集。
  3. 并发重分配。将重分配的存活对象复制到新的 Region,并维护转发关系表。
  4. 并发重映射。按照转发关系表修正对象指针的指向。

G1 存在问题的解法

  • 问题 1 的解法

与 G1 需要 STW 移动对象与修改引用不同,ZGC 采用并发移动对象与维护转发关系表的方式避免了 STW。

  • 问题 2 的解法

与 G1 一次性复制存活对象不同,ZGC 采用渐进式“假”清理以及方式清理避免。

渐进式“假”清理的含义:

  1. 渐进式:ZGC 不会一次性复制空对象,而是逐渐将存活对象复制到空 Region。
  2. 假清理: 当复制完成后,当前 Region 即标为空闲并可被后续存活对象的复制使用。当访问存活对象时,自动将旧地址刷新为转发关系表中的新地址。

放弃清理的含义:当所有对象存活,ZGC 放弃后续的复制回收过程。

低延迟的体现

ZGC 渐进式完成之前需要 STW 做的内存分配、指针修正,将串行变成并行,使应用程序感知延迟低。

但是 ZGC 也有 STW 的步骤,只与 GC Root 的数量有关:

  1. 并发标记前的 GC Root 确定
  2. 并发标记后的遗漏引用的处理
  3. 并发重分配开始前的 GC Root 新地址的计算

ZGC 仍存在的问题

  1. 新对象分配速率不能太快。因为 ZGC 为渐进式清理,垃圾回收与新对象分配并发进行,所以新对象分配速率不能高于垃圾回收的速率。否则会触发频繁的 STW 用户线程以及堆空间扩容,甚至恶化到全局的 FULL GC。

总结

纵览垃圾收集器的发展史,我觉得就是小步快跑解决存在的问题。

阶段 1 – 分代的内存布局

因为 Java 对象的“二八定律”:多数对象朝生夕灭,少数对象一直存活,所以内存布局一开始就分为新生代/老年代。

复制算法能简单的清理多数对象,标记清理算法能简单的清理少数对象,所以成为了新生代/老年代的回收算法。

内存布局对比

分代的内存布局 基于 Region 的内存布局
优点 对象引用关系简单 内存整体利用率低,垃圾收集时间可预知甚至低延迟
缺点 内存整体利用率低,垃圾收集时间不可预知 对象引用关系复杂
适用的垃圾收集器 CMS、ParNew、Serial 等 G1、Shenandoah、ZGC

阶段 2 – G1 垃圾收集器

随着 Java 应用对象个数膨胀,提升内存利用率与降低程序对垃圾收集的感知也成为了需解决的任务。

在提升内存利用率方面,内存布局从分代的几大块演变成几千块的小 Region,不再区分新生代/老年代。采用宏观上标记 - 整理算法减少内存碎片并提升利用率。在降低 STW 时间方面,G1 不再回收所有垃圾,只在特定时间内回收高价值的 Region。

阶段 3 – 低延迟垃圾收集器

G1 虽然是划时代的创新,但是也存在两个问题:

  1. 极端情况下仍需要一半空闲 Region
  2. 对象堆越大停顿时间越长

Shenandoah 通过将 STW 清理对象转变为 STW 重置对象指针解决了问题 2,但遗留问题 1 尚未解决。

ZGC 则通过将 STW 清理对象转变为 STW 重置对象指针同时解决了两个问题,但造成新的问题:新对象分配速率不允许快于垃圾收集速率。

阶段 4 – 待解决的问题

针对 ZGC 的新问题,ZGC 暂时通过提升收集速率与扩容堆解决。

但是 ZGC 的解法只是缓兵之计,收集速率的提升会导致程序分配的资源减少,堆的大小受物理内存的限制。

但是 ZGC 最大支持 T 级别内存,配套的 CPU 也不会差,上面的极端情况比较难出现,就出现了再说吧。

本文首发于cartoon的博客

转载请注明出处:https://cartoonyu.github.io