垃圾回收 —— 经典垃圾回收器

知识框架:


  1. 传统:Serial / Serial Old ~ ParNew ~ CMS ~ Parallel Scavenge / Parallel Old ~ G1
  2. 最近:Shenandoah ~ ZGC

HotSpot 虚拟机的经典垃圾回收器(图)

图中展示了七种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。上边区域是新生代收集器,下边的是老年代收集器

在 JDK8 时将 Serial + CMSParNew + Serial Old 这两个组合声明为废弃(JEP 173)
在 JDK9 中完全取消了这些组合的支持 ( JEP 214 )
在 JDK14 时,将 CMS 弃用,以及弃用了 Parallel Scavenge + Serial Old 这个组合

垃圾回收的性能指标:


  1. **吞吐量:**运行用户代码的时间 / 总运行时间(用户 + 垃圾)
  2. 垃圾收集的开销:吞吐量的补数,和吞吐量加起来为 1
  3. **低延迟(暂停时间):**执行垃圾收集时,暂停工作线程的时间
  4. 收集频率:
  5. **内存占用:**Java 堆区占用的内存大小
  6. 快速:一个对象从诞生到被回收所经历的时间

不可能三角:吞吐量、低延迟和内存占用无法同时达到最优,而其中低延迟的重要性是最高的

其中吞吐量和低延迟也是此消彼长的
由于垃圾收集线程和用户线程的切换需要消耗时间,所以:

  1. 当追求吞吐量时:

    垃圾收集次数要比较少,所以每一次收集的时间会比较长,延迟就会比较高一些,但由于线程的切换次数少,总的垃圾回收运行时间较短,吞吐量就会比较高

  2. 当追求低延迟时:

    每一次收集的时间会比较短,但要全部收集完次数就会比较多,从而导致线程的切换次数多,使得总的垃圾回收运行时间较长,所以吞吐量就会比较低

1. Serial 和 Serial Old 收集器

**特征:**单线程(串行回收、STW)、复制算法(新生代 Serial)、标记-整理算法(老年代 Serial Old)

单线程强调的是在进行垃圾收集时,会暂停其他所有工作线程直到收集结束(STW,Stop The World)

  1. Serial 是 Client 模式下新生代的的默认收集器

  2. Serial Old 是 Client 模式下老年代的默认垃圾回收器

  3. Serial Old 在 Server 模式下有两个用途

    1. 和 Parallel Scavenge 配合
    2. 作为老年代 CMS 收集器的后备方案

优缺点:


**优点:**简单高效(单线程下)、额外内存消耗最少

对于 单核或者核数很少 的处理机的环境来说,由于没有线程交互的开销,可以获得最高的单线程收集效率。而对于微服务应用来说,分配给虚拟机管理的内存一般不会很多,在这种情况下使用 Serial 收集器是一个好的选择, 所以目前依旧是客户端(-Client)的默认收集器

使用 -XX:+UseSerialGC 可以指定年轻代和老年代分别使用 Serial 和 Serial Old 垃圾收集器

2. ParNew 收集器

**特征:**新生代、并行回收、复制算法、STW

可以看作是 Serial 的多线程并行版本,除了多线程外其余行为都完全一致

是很多 JVM 在 Server 模式下新生代的默认垃圾收集器

优缺点:


**优点:**在多核处理机下,可以充分利用处理机,效率更高

**缺点:**但在单核处理器下,由于线程切换开销,反而不如 Serial 好

使用 -XX:+UseParallelGC 可以指定新生代使用该垃圾收集器
使用 -XX:ParallelGCThreads 可以指定线程数量,默认值是和 CPU 核数相同的线程数

在 JDK9 之后,ParNew 只能配合 CMS 使用了,还取消了 -XX:+UseParallelGC 这个配置,相当于称为了 CMS 专属的新生代垃圾收集器,可以说是 HotSpot 虚拟机中第一款退出历史舞台的垃圾收集器

3. Parallel Scavenge 收集器

**特征:**新生代、并行回收、复制算法、可控吞吐量、自适应调节策略

可控吞吐量表现在:该收集器提供了两个参数用于精确控制吞吐量

  1. **-XX:MaxGCPauseMillis:**控制最大垃圾收集停顿时间

    允许的值是一个大于 0 的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值

    但并不是越小就能越快,停顿时间小的代价是收集次数多,从而导致整体的吞吐量下降(线程切换开销)

  2. **-XX:GCTimeRatio:**直接设置吞吐量大小

    值是一个大于 0 小于 100 的整数,是垃圾收集时间占总时间的比率,相当于吞吐量的倒数

    如把此参数设置为 19,那允许的最大垃圾收集时间就占总时间的 5%(即1/(1+19))
    默认值为 99,即允许最大 1%(即1/(1+99))的垃圾收集时间

自适应调节表现在:收集器还提供了一个 开关参数 -XX:+UseAdaptiveSizePolicy

被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了

虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整 这些参数以提供最合适的停顿时间或者最大的吞吐量

这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics),只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用-XX:MaxGCPauseMillis参数(更关注最大停顿时间)或-XX:GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个 优化目标,那具体细节参数的调节工作就由虚拟机完成了

前边说到的 Eden : S1 : S2 = 8 : 1 : 1,但由于开启了这个参数,这个比值可能变为 6 : 1 : 1

注:Parallel Scavenge 收集器架构中本身有 PS MarkSweep 收集器来进行老年代收集,并非直接调用 Serial Old 收集器。但是这个 PS MarkSweep 收集器与 Serial Old 的实现几乎是一样的,所以在官方的许多资料中都是直接以 Serial Old 代替 PS MarkSweep 进行阐述

4. Parallel Old 收集器

**特征:**老年代、并行回收、标记-整理算法

在 JDK6 之后,成为 Parallel Scavenge 收集器的老年代版本(不用再默认选择 Serial Old 了)

在 JDK8 中,默认的垃圾回收器为 ParallelGC(包括 Scavenge 和 Old),使用 UseParallelGC 或 UseParallelOldGC 就可以同时指定这两个的使用(相互激活)

5. CMS 收集器

**特征:**新老年代(整堆)、低延迟(最短回收停顿)、标记-清除算法、并发回收、增量更新

CMS 全称为 Concurrent Mark Sweep,整个过程分为 4 部分:

  1. 初始标记(CMS initial mark)

    需要 STW,仅仅只是标记一下 GC Roots 能 直接关联到的对象,速度很快

  2. 并发标记(CMS concurrent mark)

    从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是 不需要停顿 用户线程,可以与垃圾收集线程一起并发运行

  3. 重新标记(CMS remark)

    需要 STW,作用是修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(将原本没标记的存活对象标记为存活)

    停顿时间通常会比初始标记阶段稍长一些,但远比并发标记阶段的时间短

  4. 并发清除(CMS concurrent sweep)

    清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的

整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的

**主要缺点有三个:**处理机资源敏感、无法处理浮动垃圾、容易产生内存碎片

缺点 1:处理机资源敏感


在并发阶段(标记和清除)占用了一部分处理机的计算能力,导致应用运行变慢吞吐量变低

CMS 默认启动的回收线程数是(处理器核心数量+3)/ 4
这个在核心数量为 4 或以上的,只占用不超过 25% 的计算资源
但一旦核心数不足 4 个,就会出现占用 1/3 1/2 甚至全部占用的情况,会导致用户程序的执行速度突然大幅度降低


为了解决这个问题,虚拟机提供了 CMS 的变种:“增量式并发收集器”(i-CMS,Incremental Concurrent Mark Sweep)

**其思想是:**在并发标记、清理的时候让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间。但导致的是整个垃圾收集的过程会更长,直观感受是速度变慢的时间更多了,但速度下降幅度就没有那么明显

但实践证明增量式的CMS收集器效果很一般,从 JDK 7 开始就已经被废弃了,JDK9 i-CMS 完全废弃

主要缺点 2:无法处理浮动垃圾(Floating Garbage)、会并发失败


可能出现 并发失败(Concurrent Mode Failure)进而导致另一次完全 STW 的 Full GC 的产生

在 CMS 的并发标记和并发清理阶段,由于用户线程继续运行而产生 新的垃圾,这是在标记阶段之后的 CMS 无法在 当次收集 中处理他们,只好留到下一次再清理掉,这部分就称为 浮动垃圾

而在线程继续运行时也也同样需要一部分空间,所以 CMS 不能在内存满了才开始垃圾收集,必须 预留一部分空间 供收集期间的用户线程使用,但实际运行中用户对内存的使用可能 超出预留的空间,这时就会发生并发失败,不得不启用 备用方案:冻结用户线程执行,启动 Serial Old 收集器,这样的停顿时间就很长了

对于需要预留空间,JDK5 默认是 68% 而 JDK6 是 92%,内存使用超过这个百分比就会启用 CMS
可以通过参数 -XX:CMSInitiatingOccupancyFraction 来设置,如果设置的太小会导致垃圾回收过于频繁,设置过大又会导致大量并发失败的产生性能下降,需要在生产环境衡量

主要缺点 3:容易产生内存碎片


空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次 Full GC 的情况

为了处理空间碎片的问题,CMS 提供了两个参数(在 JDK9 废弃)

  1. -XX:+UseCMS-CompactAtFullCollection 开关

    默认开启,设置在进行 Full GC 时对内存空间进行合并整理

  2. -XX:CMSFullGCsBefore-Compaction

    值为数字,设置执行多少次不进行内存整理的 Full GC 后进行内存整理

    这是因为在整内存空间碎片时时无法并发的,所以提供这个参数来避免每次 Full GC 都进行依次整理(为 0 标识每次都会整理)

6. G1 收集器

**特征:**等大小 Region、局部标记-复制、整体标记-整理、延迟时间可控、原始快照

G1 最明显的特点就是使用了 Region 来管理各个分代,任何一个 Region 都可以充当新生代和老年代,此外对于超大对象还会单独开辟出聚合区来存放,看成老年代处理。

回收的过程依旧是新生代进行标记-复制,但是复制到的 Region 是靠近起始位置的,所以从宏观上看又是标记-整理的,能够有效减少内存碎片的产生

优势 :延迟时间可控


G1 最大的特点就是延迟时间可控,用户可以设置每次垃圾回收的期望时间,默认是两百毫秒,可以根据实际情况调整到几十毫秒到几百毫秒(-XX:MaxGCPauseMillis)

延迟时间可控是通过各个 Region 的回收价值来实现的,在每次垃圾回收的过程中,都会记录涉及到的 Region 的回收耗时、卡表中脏卡的数量等,计算出均值和标准差等,作为下一次选择 Region 进行回收的参考

问题 1:跨代引用


和其他垃圾回收器一样,G1 也存在跨代引用的问题,但是这个问题在 G1 中就变得很麻烦,体现在 G1 的分区特别多 Region,每个 Region 都需要维护一个记忆集,而且为了加快搜索,记忆集中的卡表都是双向的(不仅有我指向谁,还有谁指向我),导致了记忆集的总大小很大

除去写屏障维护记忆集的操作,G1 的整个回收过程如下:

  1. 初始标记:需要 STW,标记所有的 GC Root
  2. 并发标记:和用户线程一起,扫描对象图
  3. 最终标记:需要 STW,在并发标记时记录了原始快照,在此标记就是扫描原始快照的这些引用,判断该对象是否被再次引用了避免出现对象消失问题
  4. 筛选回收:需要 STW,最后这一步 G1 需要更新并筛选出回收价值最大的 Region,同时参考用户设置的期望回收时间,只挑选一部分的 Region 进行回收

性能比较:


G1 获得可控停顿时间是通过复杂的记忆集来实现的,通常需要额外 10%~20% 的内存开销。而 CMS 的卡表则很简单占用空间少。

同时 G1 为了维护原始快照,需要使用写前屏障,维护的难度比 CMS 使用的增量更新(写后屏障)大,但却能有效减少回收时的停顿时间

所以,当机器内存较大时,G1 表现会比 CMS 优秀很多,但内存很小的情况下,CMS 可能表现的比 G1 好。

7. Shenandoah 收集器

**特点:**等大小 Region 无分代、连接矩阵取代记忆集、与用户线程并发回收

Shenandoah 可以说是 G1 的升级版,很多行为都和 G1 一样,主要的区别(也是优化)如下:

  1. 使用 连接矩阵 取代了记忆集

    前边说到 G1 的最大缺陷在于记忆集过于庞大,所以 Shenandoah 将记忆集取消掉了,换成了一个二维的连接举证,如果 Region(n) 存在对 Region(m) 的引用,则举证的第 n 行第 m 列标记为 1

  2. 能够与用户线程并发回收

    为了解决 G1 无法与用户线程并发回收的问题,Shenandoah 采用了复制存活对象的方法,主要分成了三步进行并发回收

    ① 将存活的对象复制到其他 Region
    ② 将对象的引用从旧对象更新到新对象
    ③ 清理全部的旧对象

**问题 1:**移动对象后用户线程并发访问问题(Brooks Pointer 解决)


由于用户线程每时每刻都在访问对象,如果复制对象后,不能一瞬间将所有对旧对象的引用都指向新对象,就会导致一部分访问到旧对象一部分访问到了新对象,出现数据不一致的问题

而由于对对象的引用很多,不可能一瞬间将所有的引用都更新完毕。最简单的是采用冻结用户线程,但这又会造成停顿不符合初衷。所以 Shenandoah 采用了新的方法 —— Brooks Pointer

这个指针存在于对象头中,用于指向真正的对象。在没有发生对象复制的时候,该指针指向自己,在发生对象复制后,该指针就指向了新的对象。这样只需要在发生对象复制后,将旧对象的 Brooks 指针指向新对象,那后许即使有线程访问了旧对象,也能正确地定位到新对象,从而保证了数据的一致性,如下图:

问题 2:Brooks Pinter 并发修改问题


如果收集器和用户线程发生了并发写操作,那就可能出现问题,如下:
① 收集器复制了对象
② 收集器将旧对象的 Brooks Pinter 指向新对象
③ 用户线程更新对象

上面是正常情况不会出现问题,但可以看到收集器复制和更新指针并不是原子操作,如果 ③ 发生在 ② 之前,那就会导致用户更新的数据作用到了旧对象,导致数据不一致问题

Shenandoah 的解决方法是:使用 CAS 进行对象的复制和更改指针,如果在更改指针时旧对象已经被修改了,那就需要进行重试。

问题 3:转发指针性能问题


转发指针的实现原理类似与句柄,只不过句柄有专门的句柄池来维护所有的引用关系,而转发指针则是分散到了各个对象。

那这就不可避免地,在每次访问对象时都需要额外进行一次定位操作,这本身就是一种损耗。更需要注意的是,即使是每次读取对象也需要进行引用的修改,所以 Shenandoah 不得不为每次对象访问都加上一个 读屏障,在该屏障中根据指针将引用修改到真正的对象

由于读操作频率很高,所以设置这个读屏障会很消耗性能。Shanandoah 后许打算将读屏障优化为 引用访问屏障,即:只有引用数据类型才进行拦截并修改引用,而基础数据类型则不需要

Shenandoah 的整个垃圾回收过程如下:

  1. 初始标记:STW,标记 GC Root
  2. 并发标记:扫描整个对象图
  3. 最终标记:STW,扫描原始快照,并统计出回收价值最高的 Region,构成回收集
  4. 并发清理:清理掉完全没有存活对象的 Region
  5. 并发回收:将存活的对象复制到其他 Region 并修改 Brooks Pointer
  6. 初始引用更新:实际上只是一个集合点,并没有真正执行更新,等待所有线程到达,短暂停顿
  7. 并发引用更新:扫描对象图,将所有对旧对象的引用修改为新对象
  8. 最终引用更新:修正 GC Root 中对旧对象的引用
  9. 并发清理:和第 4 步一样,所有要回收的 Region 都没有存活对象了,可以直接回收

问题 3:极端情况下需要预留出一倍的空间


从收集的过程来看,如果要收集的 Region 中几乎所有的对象都需要回收,那么就需要将这些对象全部复制一遍,等到最后面才能清除旧对象,这样在收集时就需要预留多出一倍的 Region,这个问题在 ZGC 中得到了解决,ZGC 在复制完对象就已经可以清除旧对象了,Shenandoah 不行是因为还需要通过旧对象将引用转发到新对象。

8. ZGC 收集器

**特点:**分容量 Region,无分代,染色指针代替转发指针、多重映射、转发表、与用户线程并发回收

与 G1 和 Shenandoah 不同,ZGC 的 Region 有 small、mdeium 和 large 三种容量,分别存放 0 ~ 256KB、256KB ~ 4MB 和大于 4MB 的,large 的容量为 2MB 的整数倍,且只存放一个对象

ZGC 的一个最重要的特点是染色指针,解决了转发指针每次都需要使用读屏障去定位真正对象的问题,使得并发整理算法非常高效。染色指针是直接作用在引用上的,利用了对象引用的高 4 位,分别存储了 marked0、marked1、remapped 和 finalizable 四种状态,能够达到以下的效果:

**优势 1:**存活对象被复制到其他 Region 后,旧对象就可以立即清除了(转发表 ~ 读屏障 ~ 自愈)


这里是配合了 转发表 的使用,在复制阶段,ZGC 会维护一张转发表,记录了对象的旧地址和复制到新 Region 的新地址

当有线程来请求对象时,会首先被 ZGC 设置的 读屏障 拦截,然后检查对象的染色指针,如果已经被标记为转移,则去转发表中查找对象的新地址,并且更新该引用,下次再访问时就无需通过转发表了(新引用的染色指针没有被标记为转移),这种机制在 ZGC 中称为 自愈,即只需要访问一次就能修复对象引用,而不需要都像 Shenandoah 那样每次都要转发,实现了 只慢一次

由于这整个过程都不需要用到旧对象,所以当存活对象被复制完成后,旧对象自然就可以被立即清除了

优势 2:能减少大量内存屏障的使用(染色指针 vs 三色标记)


ZGC 将状态记录到了指针中,就不需要通过写屏障去维护对象的引用情况了。同时没有分代,也不需要通过写后屏障去维护跨代引用了。

ZGC 的工作流程如下:

  1. 初始标记:STW,扫描 GC Root

  2. 并发标记:遍历对象图(其实是引用图,不需要遍历对象)

    将染色指针(对象的引用)中的 Marked0 和 Marked1 置位,这两个标记是在 GC 中轮流使用的,这一次 GC 使用了 Marked0,那么下一次就使用 Marked1,一次来区分两次 GC 时对象是否活跃而不会混淆在一起

    假设本次 GC 使用了 Marked0 标记位,那么在进行 GC 时,活跃的对象会从 Remapped 视图转移到 Marked0 视图,不活跃的留在 Marked0

  3. 最终标记:STW,对并发标记阶段中引用关系发生变化的对象进行进一步判断,防止漏标导致对象消失

  4. 并发预备重分配:需要根据特定条件统计出哪些 Region 需要清理,放入重分配集

    由于 ZGC 没有进行分代,所以会对所有 Region 进行扫描(扫描范围大了但省去了维护记忆集的成本),选出一些 Region 放到重分配集中。

  5. 并发重分配:将重分配集中的对象转移到其他 Region

    重分配集中的存活对象会被复制到其他的 Region,而所有重分配集中的 Region 将被释放。

    这个阶段会维护一张转发表,记录了旧对象到新对象的关系(具体说明见 优势 1

  6. 并发重映射:修正整个重分配集中所有旧对象的引用

    其实就是拿出转发表中的旧对象->新对象映射,将其真正地修改到对象引用中,然后就可以删除这条映射了,往后都是直接访问新对象。全部修正完毕后,转发表就可以删除了

    更新对象映射在 Shenandoah 中是一件非常紧急的事情,必须保证全部修改完毕才能保证线程访问到正确的对象。但是在 ZGC 中确实不着急的,因为即使修改之前有对象访问了,也可以通过转发表修正对象的引用(自愈),从而使对象访问到的是新对象。并发重映射阶段只是提前将引用都修改了避免要访问的时候还要进行转发

问题 1:Marked 标志位修改后中如何正确访问对象?


修改了标志位之后,其实对象的引用已经变了,正常情况下是访问不到对象的,但是 ZGC 使用了虚拟内存,进行了多重映射,将不同标记的同一个对象都映射到了同一块地址

当 Marked 标记位改变之后,其实对象是指向了不同的视图,但是根据这个视图又能指向堆内的同一个地址,这样就完成了对象的正确访问。