Skip to content

JVM 垃圾回收(下)

本文介绍各种垃圾回收器。

在介绍具体的垃圾回收器之前,先介绍两个垃圾回收的主要指标:

  • 吞吐量:运行用户代码的时间占用总运行时间的比例;

    总运行时间=用户代码运行的时间 + 垃圾回收运行的时间

  • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间,就是STW时间;

1. Serial 与 Serial Old

Serial 垃圾回收器是 JVM 中最古老也是最简单的一种垃圾回收器。它是一个单线程的垃圾回收器,这意味着在进行垃圾回收时,它会暂停所有其他正在运行的应用程序线程(即 Stop-The-World,STW),然后由一个单独的线程来完成所有的垃圾回收工作。

Serial 垃圾回收器在新生代和老年代使用不同的回收算法:

  1. 新生代回收(Serial):
    • Serial 回收器在新生代使用的是复制算法
    • 当新生代的 Eden 区满了之后,触发 Minor GC。
    • JVM 会暂停所有的应用程序线程(STW)。
    • 使用一个单独的 GC 线程,将 Eden 区和其中一个 Survivor 区(假设是 Survivor A)中所有存活的对象复制到另一个空的 Survivor 区(Survivor B)。
    • 复制完成后,Eden 区和 Survivor A 区的所有对象都被清除。
    • 下次 Minor GC 时,存活的对象会从 Eden 区和 Survivor B 区复制到 Survivor A 区,以此类推,两个 Survivor 区会轮流使用。
  2. 老年代回收(Serial Old):
    • Serial 回收器在老年代使用的是 标记-整理算法
    • 当老年代的空间不足时,或者在新生代 Minor GC 之后存活的对象无法放入 Survivor 区时,可能会触发 Full GC,对老年代进行回收。
    • JVM 会暂停所有的应用程序线程(STW)。
    • 使用一个单独的 GC 线程执行以下步骤:
      • 标记(Mark): 从 GC Roots 开始遍历老年代中的所有对象,标记出所有存活的对象。
      • 清除(Sweep): 遍历老年代,回收所有没有被标记的对象所占用的内存空间。这个阶段会产生内存碎片。
      • 整理(Compact): 将所有存活的对象都移动到内存的一端,然后清理掉边界以外的内存。这个阶段可以消除内存碎片,但会带来额外的性能开销。

下图展示了新生代和老年代都采用Serial收集器:

image-20250413172836866

Serial 垃圾回收器的优点

  • 简单高效(对于单线程环境): 由于只使用一个线程进行垃圾回收,在单线程环境下,没有线程切换的开销,因此效率相对较高。
  • 内存占用少: 相对于需要多个 GC 线程的并发回收器,Serial 回收器对内存的要求较低。

Serial 垃圾回收器的缺点

  • 停顿时间长: 这是 Serial 回收器最主要的缺点。由于在进行垃圾回收时会暂停所有的应用程序线程(STW),对于需要高响应性的应用程序来说,长时间的停顿是无法接受的。尤其是在堆内存较大或者存活对象较多的情况下,停顿时间会更加明显。
  • 不适合并发场景: 因为是单线程回收,所以无法充分利用多核处理器的优势,不适合现代多线程并发的应用场景。

适用场景

由于其缺点,Serial 垃圾回收器在现代服务器端应用中已经很少被直接使用。它通常适用于以下场景:

  • Client 模式下的默认新生代回收器: 在 JVM 的 Client 模式下(通常用于桌面应用或开发测试环境),Serial 回收器是默认的新生代回收器。
  • 内存资源有限的环境: 在一些内存资源非常有限的嵌入式系统中,Serial 回收器由于其简单性和较低的内存占用可能仍然是一个可行的选择。
  • 单线程应用: 对于一些简单的单线程应用程序,如果对停顿时间没有严格的要求,Serial 回收器也可以使用。
  • 作为其他回收器的备选项: 在某些情况下,例如 CMS 回收器发生 "Concurrent Mode Failure" 时,可能会退化使用 Serial Old 回收器进行 Full GC。

如何启用 Serial 垃圾回收器

可以通过 JVM 启动参数来显式地启用 Serial 垃圾回收器:

bash
-XX:+UseSerialGC

这个参数会指定新生代和老年代都使用串行的垃圾回收器。

2. ParNew

ParNew 是Serial收集器的多线程版本,只负责新生代的垃圾回收。

ParNew收集器除了采用并行回收的方式执行垃圾回收外,和Serial收集器之间几乎没有任何区别。ParNew收集器在新生代同样采用复制算法、STW机制。

下图展示了新生代采用ParNew收集器,老年代采用Serial Old 收集器:

image-20250413173137935

我们可以通过一下参数配置ParNew收集器:

  • -XX:+UseParNewGC:年轻代使用ParNew收集器回收垃圾;
  • -XX:ParallelGCThreads:设置ParNew用于垃圾回收的线程数量,默认开启和CPU数相同的线程数;

3. Parallel 与 Parallel Old

Parallel 全称是Parallel Scavenge,与ParNew一样,针对于新生代,是基于并行回收的、采用复制算法、同样有STW机制。

但是,与ParNew不同的在于:

  • Parallel 收集器的目标是达到一个可控制的吞吐量,也被称为吞吐量优先的垃圾收集器;
  • Parallel 收集器有自适应调节策略;

自适应调节策略是JVM 会不断地监控系统的运行状态和垃圾回收的行为,并根据这些信息动态地调整新生代的大小(包括 Eden 区和 Survivor 区的比例)、老年代的大小,以及晋升(Promotion)到老年代的对象年龄等参数。

Parallel Old收集器在JDK 1.6出现,用于执行老年代垃圾收集,同样基于并行回收、采用标记-整理算法、同样有STW机制。

下图展示了新生代使用Parallel收集器,老年代使用Parallel Old收集器:

image-20250413174326335

Parallel收集器是以吞吐量为目的的垃圾收集器,所以可以高效利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务,例如:批量处理、订单处理、科学计算等。

在Java 1.8 中,Parallel收集器是默认的垃圾收集器

有关Parallel收集器的参数如下:

  • -XX:+UseParallelGC:指定年轻代使用Parallel收集器;

  • -XX:+UseParallelOldGC:指定老年代使用Parallel Old收集器;

    上面两个参数,默认开启一个,另一个也会被激活。

  • -XX:ParallelGCThreads:设置年轻代垃圾收集器使用的线程数;

  • -XX:MaxGCPauseMillis :设置垃圾收集器最大停顿时间(即STW的时间),单位是毫秒。

  • -XX:GCTimeRatio :设置垃圾收集时间占总时间的比例,用来衡量吞吐量的大小,取值范围为(0,100),默认值为99,也就是垃圾回收时间不超过总时间的1%。若设置值为N,则计算公式为1/(N+1)

  • -XX:+UseAdaptiveSizePolicy:开启自适应调节策略,可以将+改为-来关闭该策略;

4. CMS

4.1 概述

CMS,全称Concurrent Mark Sweep,是一种以获取最短回收停顿时间为目标的收集器,基于标记-清除算法,回收老年代。年轻代一般配合ParNew垃圾回收器使用。

CMS诞生于JDK 1.4,在JDK 9 中被标记为已过期的,在JDK 14中被彻底移除,但是后续的垃圾回收器借鉴了CMS的思想,所以学习CMS的工作原理还是很重要的。

CMS有如下特点:

  • CMS是第一个关注停顿时间与用户体验的垃圾回收器;
  • CMS是HotSpot虚拟机第一款真正意义上的并发收集器(并发是指垃圾回收线程可以和用户线程同时执行);
  • CMS采用了三色标记算法,实现了垃圾回收线程和用户线程的并发执行;

4.2 工作流程

CMS的工作流程如下:

  1. 初始标记:标记出 GC Roots 能直接关联到的对象。GC Roots除了传统意义上的GC Roots,还包括年轻代中的对象(因为CMS是老年代的垃圾回收器)。

    这一步是STW的,会造成程序暂停,但是时间很短,因为只标记GC Roots的直接引用对象。

    image-20250414205800129

  2. 并发标记:从第一步标记的对象触发,沿着引用链标记出存活的对象。这一步是并发的,不会造成程序的暂停。但是由于是并发的,所以会造成对象的误标和漏标。对象误标会产生浮动垃圾,但是可不做处理,待下次垃圾回收周期完成正确标记。对象漏标包括两种情况:

    • 在并发标记期间,有新对象进入到老年代(例如新生代对象晋升到老年代、在老年代中直接分配大对象),这些新进入老年代的对象没有被标记;
    • 在并发标记期间,对象引用关系做了修改,例如,从A(未标记)->C变为了B(已标记)->C,),由于B对象已经标记了,不会沿着B对象引用链继续标记,所以会造成C对象漏标;或者新生代对象引用了未被标记的老年代对象,也会造成对象漏标。

    image-20250414210033491

  3. 并发预清理:第三步是可选的,主要目的是解决第二步中并发标记产生的对象漏标问题(采用Card Table+增量更新技术解决),其实第五步也可以处理,但是并发预处理是为了减少第五步的STW时间。

  4. 可中断的并发预清理:主要作用如下,

    • 避免预清理阶段无限期执行: 在某些情况下,如果对象的引用关系持续快速变化,预清理阶段可能需要很长时间才能稳定下来。可中断的设计允许 CMS 在这个阶段运行一段时间后,即使预清理工作尚未完全“稳定”,也可以主动中断,进入 Remark 阶段。

    • 等待一次 Young GC 发生: 这是“可中断”设计的一个关键优化点。Remark 阶段需要扫描从 GC Roots(包括新生代对象)出发的可达对象。如果在 Remark 开始时,新生代(尤其是 Eden 区)有很多对象,那么 Remark 的扫描范围就会很大,导致 STW 时间变长。可中断预清理阶段会有意识地等待,希望在自己运行期间能发生一次 Young GC。

      • Young GC 会清空 Eden 区,并将存活对象移到 Survivor 区或老年代。

      • 如果在 Young GC 之后再开始 Remark,那么 Remark 需要扫描的新生代对象就会大大减少(可能只需扫描 Survivor 区),从而显著缩短 Remark 的 STW 时间

    • 控制 Remark 启动时机: 通过设置一些条件(如等待时间、Eden 区使用率阈值),CMS 可以在预清理阶段中“暂停”或“等待”,直到满足特定条件(比如发生了一次 Young GC,或者等待超时(默认5秒))再结束预清理,然后启动 Remark。这使得 CMS 对 Remark 阶段的停顿时间有了一定的间接控制能力。

  5. 最终标记(最终标记):利用Card Table+增量更新技术,处理并发标记阶段产生的对象漏标问题,会产生STW时间。对象漏标主要产生原因如下:

    • 对象从新生代晋升到老年代(新增)
    • 大对象直接分配到老年代(新增)
    • 新生代对象引用未被标记的老年代对象(变化)
    • 已标记的老年代对象引用未被标记的老年代对象(变化)

    在这一阶段主要完成的工作如下:

    • 遍历新生代对象,重新标记被引用的老年代对象;
    • 扫描GC Roots,重新标记被引用的老年代对象;
    • 遍历卡表(Card Table),对脏页内的老年代对象进行重新标记;

    image-20250414212612299

  6. 并发清除:GC 线程清除掉未被标记的(即判定为垃圾的)对象,回收它们所占用的内存空间,这一步是并发的。

  7. 并发重置:重置 CMS 内部的数据结构,为下一次 GC 循环做准备。

image-20250414205155605

有的时候,我们也可以将CMS工作流程简化为四步:

  • 初始标记
  • 并发标记
  • 最终标记
  • 并发清除

4.3 卡表与写屏障

卡表(Card Table)并不是 CMS 特有的,它是分代垃圾回收(Generational Garbage Collection)中常用的一种优化技术,解決跨代引用(尤其是老年代指向年轻代的引用)的扫描效率问题。

当我们进行年轻代垃圾回收时,不仅需要从GC Roots出发,也需要从老年代对象出发,寻找在年轻代中的直接引用。但是,由于老年代中的对象众多,如果采取遍历的方式,无疑使得效率大大降低。所以采用空间换时间的思想,记录老年代中的哪个对象引用了年轻代中的对象,在进行年轻代垃圾回收时,只需要遍历记录的老年代对象,提高效率。

但是,如果要记录老年代中每个引用了年轻代对象的对象,无疑会大大增加内存耗用。为了在时间与空间之间做一个折中,JVM采用了卡表技术。

卡表的作用是记录哪些区域的老年代对象可能持有年轻代对象的引用。

卡表的工作机制如下:

  • 分区:将老年代的堆内存划分为一个个大小固定的卡页,卡页的大小通常是2的幂次方字节,例如512字节;
  • 映射:创建一个数据结构(通常是一个字节数组),称为卡表。卡表中的每一个元素对应老年代中的一个卡页;
  • 写屏障(Write Barrier):JVM在执行引用类型字段赋值操作时,会插入一小段额外的代码,这就是写屏障。
    • 当一个老年代对象的引用字段被修改,指向了一个年轻代对象时,写屏障就会触发执行;
    • 写屏障会找到该老年代对象所在的卡页,并将卡页对应的卡表标记为设置为脏(dirty)状态,通常是将值从0改为1。
  • 年轻代垃圾回收:当进行年轻代垃圾回收时(Minor GC)时,垃圾回收器不需要扫描整个老年代对象,只需要遍历卡表,找到被标记为脏的元素,然后扫描这些脏卡对应的老年代卡页中的对象,将这些对象加入GC Roots集合中。

在CMS中,卡表技术也有发挥作用。写屏障不仅会标记老年代对象指向年轻代对象的引用关系修改,也会标记所有发生引用关系修改的卡页(即发生了老年代对象A指向老年代对象B的引用修改,老年代对象A所在的卡页也会被标记为脏状态)。在重新标记阶段,CMS会扫描脏卡页,以确保对象标记状态的准确性。

4.4 参数设置

有一些虚拟机参数可以配置CMS:

  • -XX:+UseConcMarkSweepGC:是否启用CMS垃圾回收器;
  • -XX:CMSInitiatingOccupancyFraction:当老年代堆空间使用达到该比例时会触发CMS GC,默认值是92(百分比);
  • -XX:+UseCMSInitiatingOccupancyOnly:如果不执行,CMS仅在第一次进行垃圾回收时参考-XX:CMSInitiatingOccupancyFraction设定值,后续则会根据运行时数据做自动跳转;如果指定该参数,那么每次GC时都会参考设定值;
  • -XX:+CMSClassUnloadingEnabled:CMS收集器默认不会对元空间进行垃圾回收,该值用于设定CMS是否对元空间进行垃圾回收;
  • -XX:UseParNewGC:默认开启年轻代中的ParNew垃圾收集器;
  • -XX:+CMSParallelRemarkEnabled:默认为true,在重新标记的时候多线程执行,缩短STW;
  • -XX:+CMSScavengeBeforeRemark:默认为false,在重新标记前强制进行Minor GC(年轻代垃圾回收),这样能降低重新标记阶段扫描年轻代对象的时间;
  • -XX:+UseCMSCompactAtFullCollection:默认为true,在Full GC后做压缩处理;
  • -XX:CMSFullGCsBeforeCompaction:多少次Full GC后压缩一次,默认是0,代表每次Full GC后都会压缩一次;
  • -XX:ConcGCThreads:CMS并发的GC线程数;

5. G1

5.1 概述

G1 垃圾回收器在JDK 1.7 update 4之后引入,全称为Garbage First Garbage Collector。G1是一个分代的、增量的、并行与并发的垃圾回收器。它的设计目标是为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间,同时兼顾良好的吞吐量。

G1在JDK 9成为默认的垃圾回收器。

G1收集器的特点:

  • 引入了分区的概念;
  • 收集过程和停顿时间可控;
  • 消除了CMS中的内存碎片和浮动垃圾问题;
  • 适用于多处理器、内存较大的机器;

5.2 内存模型

G1 垃圾收集器将堆内存划分为若干个Region分区,在特定的时间,每个Region只能是一种角色,可以是E区、S区、O区或H区。

  • E区代表年轻代中的 Eden 区;
  • S区代表年轻代中的 Survivor 区;
  • O区代表老年代 Old 区;
  • H区用于存储大对象,如果一个对象的大小超过Region容量的50%,那么G1就认为这是个大对象,将其存放在H区;

下图演示了堆内存分区情况(白色区域代表空闲区域)。

每个Region的角色并不是固定不变的,在不同的时间,Region可能是E区,也可能是S区,也可能是O或H区。

5.3 记忆集与卡表

G1 垃圾回收器将堆内存划分为不同的Region分区,在垃圾回收时,不是对整个堆空间进行回收,而是对某些Region进行回收。这样,当进行可达性算法分析时,不仅要从原来的GC Roots出发,还要从其他Region分区出发,标记出该Region中的初始存活对象。

由于G1适用于大内存机器,通常会将堆内存划分为约2000个分区,对于某个分区A,如果遍历其余的每个分区来标记分区A的中初始存活对象,无疑是非常耗时的。基于跨代引用和跨区引用的问题,G1提出了记忆集(Remembered Set,简称RSet)这一数据结构。

每个分区都有一个RSet,其中保存了哪个分区引用了本分区的对象。例如,在下图中,Region 2中的RSet中的内容是{1,3},表示分区1和分区3引用了本分区(Region 2)的对象。

有了RSet,G1就可以只遍历RSet中记录的分区,不需要遍历其他所有的分区,从而提升效率。

结合卡表(Card Table),在每个分区中,我们会继续将分区划分为一个个大小固定的卡页(通常为512字节),并且维护卡表。当引用关系发生变化时,会将对应的卡页设置为脏状态。这样就进一步减少了需要遍历的内存大小。例如,在Region 2中,RSet记录的结果是{1,3},表示Region 1 和 Region 3 中的对象引用了Region 2中的对象,我们找到Region 1,然后查看Region 1 的卡表,发现索引为1的卡页是脏状态,进而缩小遍历范围,只需要遍历索引为1的卡页中的对象,从而找到Region 2中的初始存活对象。

有引用关系变化时,JVM通过写屏障来更新记忆集与卡表。

记忆集(RSet)记录了其他Region中的对象引用本Region中对象的对象,属于points-into结构,即谁引用了我的对象;

卡表(Card Table)记录了本卡页中的对象引用外部对象关系变化了的状态,是一种points-out结构,即我引用其他对象变了。

RSet配合卡表.jpg

5.4 G1的工作原理

G1 的工作模式分为三种:Young GC、Mixed GC和Full GC。

5.4.1 Young GC

触发时机:当 Eden 区被占满无法分配新对象时触发Young GC。

工作内容:

  • 完全是STW的;
  • 只回收年轻代Region(包括E区和S区);
  • 采用复制算法,将存活对象复制到新的S区或提升到O区;
  • 回收完成后,就的E区和S区被释放,变为空闲区域;

程序不断运行,不断分配对象,触发Young GC,G1会动态调整年轻代的大小(即E区和S区的数量),来满足-XX:MaxGCPauseMills设置的停顿时间目标。

5.4.2 Mixed GC

当老年代O区占用整个堆空间大小超过-XX:InitiatingHeapOccupancyPercent设定的值(默认45%)时,G1会开始并发标记周期,并发标记周期流程如下:

  • 初始标记:仅仅是标记出GC Roots和外部Region直接关联的对象,这一步需要STW,但是耗时很短。事实上,当老年代占比达到-XX:InitiatingHeapOccupancyPercent设定的值时,G1并不会立即发起并发标记周期,而是等待下一次年轻代收集,利用年轻代收集的STW时间,完成初始标记,这种方式称为借道。
  • 并发标记:从初始标记阶段中标记的对象出发,进行可达性分析,扫描整个堆中的对象,找出要回收的对象。这阶段耗时很长,但是可以与用户线程并发执行。由于与用户线程并发执行,所以会出现对象误标和漏标,G1采用初始快照方法解决。
  • 最终标记:处理并发标记阶段产生的对象误标和漏标问题,这一步需要STW。
  • 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划。这个阶段并不会实际去做垃圾的回收,也不会执行存活对象的拷贝,主要识别回收效益较高的分区。这一阶段需要STW。

流程如下:

image-20250415155340006

在并发标记周期完成后,G1就知道哪些O区有可回收的垃圾,这些Region称为Collection Set(回收集)。如果G1判断有必要回收老年代空间(基于可回收空间大小、用户设定的暂停时间等),就会开始执行一次或多次Mixed GC。Mixed GC内容包括:

  • 是STW的;
  • 执行Young GC;
  • 回收一部分在并发标记周期中被识别出来的、可回收价值较高的老年代Region(Garbage First的体现);
  • 采用复制算法;

在Mixed GC中,有如下注意点:

  • 并发标记结束后,老年代中百分百为垃圾的O区被回收;
  • G1通常不会在一次Mixed GC中回收所有有垃圾的老年代Region,而是分批进行(可通过-XX:G1MixedGCCountTarget设置最大回收次数),每次选择一部分,以确保单次STW时间满足用户设定的目标;
  • 如果某个Region的垃圾占比太低,G1会选择不回收该区域,因为存活的对象太多,复制会花更多时间,但效果不佳,可以通过-XX:G1MixedGCLiveThresholdPercent设定(默认值为85%);
  • 如果发现可以回收的垃圾占回收集空间的比例过小,说明垃圾太少,此时进行Mixed GC会花费太多时间,但是回收到的空间却很少,因此G1会选择不进行Mixed GC。我们可以通过-XX:G1HeapWastePercent来设置这个比例(Jdk7中默认值为10%,Jdk11中为5%),即垃圾占回收集空间的比例小于5%,则不会进行Mixed GC。

5.4.3 Full GC

这是G1的兜底模式,当G1在执行Young GC和Mixed GC过程中失败时出发,常见的触发原因包括:

  • 提升失败:老年代没有足够的空间容纳从年轻代晋升的对象;
  • 转移失败:在Mixed GC中,无法为老年代的存活对象找到新的位置;
  • 大对象分配失败:无法找到连续的Region来容纳大对象;

当发生Full GC时,会执行一次STW(耗时)、对整个堆进行标记-整理的垃圾回收,这通常是非常耗时的,所以需要避免发生Full GC。

5.5 参数设置

  • -XX:+UseG1GC:是否启用G1垃圾回收器;
  • -XX:G1HeapRegionSize:Region的大小,范围是1-32M,必须为2的幂次方大小,该值的设定目标应基于堆内存大小,设定约2048个Region。如果未指定,则根据堆内存大小动态计算;
  • -XX:MaxGCPauseMillis:指定最大停顿时间,默认值是200毫秒;
  • -XX:ParallelGCThreads:并发GC线程数(STW时),CPU<=8时取CPU,否则取3+5/8的CPU核心数
  • -XX:ConcGCThreads:并发GC线程数(非STW),默认为ParallelGCThreads的1/4左右
  • -XX:G1UseAdaptiveIHOP:默认为true,启动初始堆的自适应策略(针对-XX:InitiatingHeapOccupancyPercent)
  • -XX:InitiatingHeapOccupancyPercent:默认值45%,触发全局并发标记周期(可以理解为Mixed GC)的老年代使用占比;
  • -XX:G1NewSizePercent:默认为5%,新生代内存初始空间
  • -XX:G1MaxNewSizePercent:默认为60%,新生代内存最大空间
  • -XX:G1MixedGCLiveThresholdPercent:默认85%,存活对象低于85%的Region才可以被回收,配合-XX:+UnlockExperimentalVMOptions使用
  • -XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收(默认8次),在最后一次筛选回收阶段可以回收一部分,然后暂停回收,恢复系统运行,一会再继续回收,这样可以不至于单次停顿时间过长
  • -XX:G1HeapWastePercent:默认5%,触发Mixed G次的堆垃圾占比,如果垃圾占回收集的比例低于此值,则停止Mixed GC;

6. ZGC

6.1 概述

ZGC(The Z Garbage Collector)是在JDK 11中推出的一款追求极致低延迟的并发的垃圾回收器,在JDK15中逐渐稳定可用,具有以下特点:

  • 最大停顿时间(STW)不超过10ms(在JDK 16中,最大停顿时间不超过1ms);
  • 支持的堆内存大小可到4TB(在JDK 15及之后,最大内存支持16TB,参考JEP 377);
  • 停顿时间不会随着堆内存的增加而增加(在此之前的垃圾回收器的停顿时间都与堆大小有关系);

6.2 内存布局

本文主要介绍非分代的ZGC,在JEP 439中提出了分代的ZGC,并且已在JDK 21中发布;在JEP 490提出移除非分代的ZGC,并且已在JDK 24中发布。

最开始的ZGC是不分代的,因为分代的实现很复杂。

ZGC的内存布局,与G1比较类似,采用Region分区的概念。ZGC的Region可以具有小、中、大三种规格:

  • 小型Region(Small Region):容量固定为2M,存放小于256K的对象;
  • 中型Region(Medium Region):容量固定为32M,存放大于等于256K但小于4M的对象;
  • 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象;

一个小型或中型Region可以容纳多个对象,但是一个大型Region只能容纳一个对象。

image-20250416144253073

6.3 颜色指针与读屏障

在ZGC中,对象引用指针总是64位的(因此,ZGC不支持32位平台和指针压缩)。

由于现有系统可用内存远远小于264大小,所以我们只需要使用一部分来表示对象地址,其余部分用来存储元数据信息,这就是颜色指针的原理。

下图展示了64位对象引用指针的构成(最大支持内存空间为4TB版本):

image-20250416145915877

在对象引用指针的64位中,分别为:

  • 18位:预留给以后使用;
  • 1位:Finalizable标识(简称F位),如果该标志位被设置,它表示这个这个指针只能由finalizer线程使用;
  • 1位:Remapped标识(简称R位),表示进入重分配阶段;
  • 1位:Marked 1标识(简称M1位)
  • 1位:Marked 0标识(简称M0位),和上面的Marked 1都是标记对象用于辅助ZGC过程
  • 42位:表示对象的地址,最大可支持4TB地址空间,在JDK 15及之后,对象地址被扩展为44位,预留地址被缩减为16位,最大可以支持16TB地址空间;

指针颜色由元数据位确定,颜色分为两种:好与坏。好颜色只有三种:0100、0010、0001,即R、M1、M0中的一个位为1, 其他三位都为0。在任意时间点,有一个全局的好颜色指针标识,并且在ZGC的工作流程(后续介绍)中会变化:

  • 在STW 1流程中,全局好颜色在M1(0010)和M0(0001)之间切换(所谓切换,是指第一个GC流程中,全局好颜色被设置为M1,在第二个GC流程中,全局好颜色被设置为M0);
  • 在STW 3流程中,全局好颜色被设置为R(0100);

一旦全局好颜色确定了,其他的指针颜色都被认为是坏的。另外,创建对象时,新的对象指针颜色被设置为当前的全局好颜色。

读屏障(Load Barrier)是指当应用线程从堆中读取对象引用时,会执行某段代码。例如:

java
Object o = obj.field;
//只有obj.field是对象引用时,才会触发读屏障,执行某段代码

下面的情况都不会触发读屏障:

java
Object p = o;       // 不是从堆中读取
o.doSomething();    // 不是从堆中读取
int i = obj.field;  // obj.field是基本数据数据类型

6.4 工作流程

本小节介绍ZGC的工作流程,主要分为如下阶段:

  • STW 1 并发标记开始:这一步是STW的,主要完成以下任务

    • 确定全局好颜色是什么,在M1和M0之间轮流选择,假设此处选择M0;
    • 创建新的Region,在STW 1后本次GC循环中,用户线程创建的对象直接放置在新的Region中。在本次GC循环之前存在的Region作为垃圾回收区域。
    • 从GC Roots触发的对象引用指针被修改为好颜色状态,并被放入标记栈(Mark Stack)中,待后续使用;
  • 并发标记阶段/并发重映射阶段:这一阶段中,GC线程和用户线程同步执行。这一时间段是两个阶段的重合:并发标记阶段和并发重映射阶段。

    • 并发标记阶段:GC线程从标记栈出发,遍历对象图,并将活跃对象(可达对象)指针颜色变为好颜色M0;
    • 并发重映射阶段:这是GC回收最后的阶段,主要负责指针自愈(self-healing)工作,下面会详细介绍。
  • STW 2并发标记阶段结束:这一步是STW的,主要是确定并发标记阶段的结束。由于在并发标记阶段,标记栈是不断变化的,所以GC线程需要和工作线程通信,来确保所有存活对象都已被标记了;

  • 选择回收区域集合:根据并发标记阶段的结果,选择回收区域集合,即Region集合。

    注意,如果某个Region百分百为垃圾对象,那么会立即回收。

    基于上面的决策,大型Region不会被选择为回收区域,因为大型Region只包含一个对象,要么存活要么死亡,如果死亡则立即回收,如果存活,由于大对象复制效率较慢,所以不会选择回收。

    因此,回收区域集合只包含小型和中型Region。

    入选回收的Region也要满足一定要求,如果垃圾过少,回收效率过低,则放弃回收。

    另外,这一阶段会把前一个GC循环的回收区域集合和转发表(forwarding table)置空(因为在并发标记/并发重映射阶段,已完成了对象指针自愈)。

  • STW 3 切换到转移阶段:这一步是STW的,主要完成以下任务:

    • 将全局好颜色状态改为R(0100);
    • 从GC Roots出发,将直接引用指针变为好颜色,并且,如果直接引用指针指向的对象在回收区域集合,会把该对象转移到新Region中;
  • 转移阶段:这一阶段主要由GC线程同步完成,将回收区域集合中的存活对象转移到新Region中。

    • 首先,会在堆外内存中创建一个转发表(forwarding table),其中记录了转移对象的旧地址和新地址;

      在堆外内存创建转发表(而不是在回收区域中创建),好处是将回收区域中的存活对象全部转移后,就可以回收内存了

    • 然后,GC线程遍历回收区域集合,将其中的存活对象转移到新Region中,并在转发表中创建一条记录,转移对象旧地址到新地址的映射;

    • 转移阶段完成后,转发表存储着所有转移对象的新地址;

  • 并发重映射阶段/并发标记阶段:这一阶段是与并发标记阶段同时发生,主要完成指针自愈工作,工作线程和GC线程都可以完成指针自愈。

    • 由于是下一个GC循环的并发标记阶段,所以全局好颜色已经变为了M1;
    • 当线程通过指针访问对象时,如果发现指针指向的地址在转发表中的旧地址,则将指针更新为新地址;
    • 最后,将指针更新为好颜色状态M1;

    下图展示了ZGC的工作流程(也可以查看参考资料[3]中的PPT 第55页开始):

ZGC流程

下图展示了ZGC循环中并发标记和并发重映射阶段重合后的GC循环:

image-20250416165514013

6.5 为什么要有Remapped标志

在转移阶段,GC线程和用户线程是同步进行的,如果没有对象引用指针中没有Remapped标志,那么用户线程每次通过指针访问对象时,都需要查询转发表,来判断对象有没有转移,这会降低程序运行效率。

在并发标记/并发重映射阶段(代码见下图1),由于全局好颜色被修改为M1/M0,没有了Remapped标志,所以,每次拿到对象指针时,都需要判断对象是不是转移过了,如果是,则需要将对象指针修改为新对象地址(代码见下图2)。

图1:并发标记/并发重映射阶段GC线程逻辑

图2:读屏障指针自愈

(a)图显示了GC线程治愈指针的逻辑;(b)图显示了工作线程治愈指针的逻辑;

6.6 参数设置

  • -XX:+UseZGC:启用ZGC垃圾回收器;

7. 总结

本文介绍了8中垃圾回收器,但是也存在其他没介绍的垃圾回收器,比如Epsilon,Shenandoah。下表列举了垃圾回收器的基本知识:

垃圾回收器工作区域分类回收算法特点
Serial年轻代串行复制算法吞吐量优先
Serial Old老年代串行标记-压缩算法吞吐量优先
Parallel年轻代并行复制算法吞吐量优先
Parallel Old老年代并行标记-压缩算法吞吐量优先
ParNew年轻代并行复制算法响应速度优先
CMS老年代并发标记-清除算法响应速度优先
G1年轻代、老年代并行、并发复制算法、标记-压缩算法响应速度优先
ZGC-并行、并发复制算法响应速度优先

8. 垃圾回收器的组合

在JDK 1.8之前,垃圾回收器组合如下:

image-20250417124549290

在JDK 1.8时,垃圾回收器组合如下:

image-20250417124614144

由于维护和兼容性测试的成本,在JDK 1.8时,将Serial + CMS,ParNew + Serial Old这两个垃圾回收器的组合声明为废弃,并在JDK 9中完全取消了这些组合的支持。

在JDK 1.8之后:

image-20250417124742231

在JDK 9中,弃用了CMS 垃圾回收器(JEP291);

在JDK 14中,弃用了Parallel + Serial Old的组合(JEP366),删除了CMS垃圾回收器(JEP363);

在JDK 15中,ZGC正式商用;

下文展示了JDK 版本中默认的垃圾回收器:https://blog.gceasy.io/what-is-javas-default-gc-algorithm/

参考资料

[1] 在G1中,什么时候触发Mixed GC :https://heapdump.cn/article/2712390

[2] ZGC 实现原理论文解读:https://dl.acm.org/doi/fullHtml/10.1145/3538532

[3] ZGC 实现原理PPT解读:https://www.jfokus.se/jfokus18/preso/ZGC--Low-Latency-GC-for-OpenJDK.pdf

[4] 默认垃圾回收器:https://blog.gceasy.io/what-is-javas-default-gc-algorithm/