JVM 垃圾回收
垃圾回收算法
标记-清除
Mark-Sweep,分为两步:首先标记出需要回收的对象;标记完成后,统一回收被标记的对象。实现简单,但两个过程的执行效率都随对象数量增长而降低,并且会造成内存碎片。

标记-复制
Mark-Copy,核心思想是半区复制,即将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当一块内存用完,就将还存活的对象复制到另一块上面;然后把已使用的内存块一次性清理掉;最后交换两个内存块的角色。
优点是实现简单,运行速度快,复制保证了空间的连续性,没有内存碎片;但缺点也很明显,将可用内存缩小为原来的一半。适合对象快速迭代,垃圾对象较多的场景,可以减少复制操作。

标记-整理
Mark-Compact,分为两步:首先标记出需要回收的对象;然后让所有存活对象都向内存空间的一端移动,最后直接清理掉边界以外的内存。
优点是不会产生内存碎片,缺点是需要移动大量对象,处理效率比较低,需要 STW。是一种适合老年区的移动式算法。

分代收集
分代收集的理论基于两个假说:
- 弱分代假说:绝大多数对象都是朝生夕灭的
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡
JVM GC 分代的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域:
- 新生代:每次垃圾收集都会有大批对象死去,适合标记-复制算法
- 老年代:新生代中每次回收后存活的少量对象,将会逐步晋升到老年代,适合标记-清除/整理算法
针对不同的分代,可以分别进行垃圾回收,例如新生代收集Minor GC,老年代收集Major GC,混合收集 Mixed GC,以及整堆收集 Full GC。
Hotspot 的堆内存结构就是基于分代的设计:
- 新生代划分为一块较大的
Eden空间和两块较小的Survivor空间,每次分配只是用Eden和其中一块Survivor - GC时,将
Eden和Survivor中存活对象一次性复制到另一块Survivor,然后清理Eden和原先的Survivor - 最后交换两个
survivor区的角色 - 逃生门设计:当
Survivor不足以容纳一次GC的存活对象时,需要依赖其它内存区域(如老年代)进行分配担保

垃圾回收实现
基于上述的垃圾回收算法,结合分代回收的理论,针对不同分代的特点,设计出了不同的垃圾收集器:
- 新生代收集器:Serial、ParNew、Parallel Scavenge
- 老年代收集器:Serial old、Parallel old、CMS
- 整堆收集器:G1
- 新一代收集器:Shenandoah、ZGC

Safe Point:垃圾回收的安全点是指程序执行时,程序能够安全地停止下来进行垃圾回收的位置。在Java虚拟机中,安全点通常是指一组特定的指令位置,在这些位置上,虚拟机能够保证对象引用关系的一致性,即对象引用关系不会发生变化。常见的安全点选择策略包括轮询(Polling)、中断(Interrupt)和内存屏障(Memory Barrier)等。
Serial
Serial 是经典的串行垃圾收集器,主要用于新生代,采用标记-复制算法。垃圾回收时 STW 直到收集结束。
Serial Old 是 Serial 的老年代版本,同样是单线程串行并且会 STW,但采用标记-整理算法。

ParNew
ParNew 是 Serial 的多线程版本,它充分利用多核处理器的优势,通过并行的方式进行垃圾回收,专注于提供更低的垃圾收集停顿时间。主要用于新生代,采用标记-复制算法。另外,除Serial外只有ParNew可以和CMS配合使用。

Parallel
Parallel Scavenge 是一种以吞吐量为主要设计目标的并行垃圾收集器,对于单次垃圾收集造成的暂停时间可能会略高于ParNew收集器,但在整体吞吐量上会更加高效。主要用于新生代,采用标记-复制算法。
$$ \text{吞吐量} = \frac{\text{运行用户代码时间}}{\text{运行用户代码时间} + \text{运行垃圾收集时间}} $$
Parallel Old 是 Parallel 的老年代版本,同样是多线程并行,注重吞吐量,但采用标记-整理算法。

ParNew 更适合那些对垃圾收集停顿时间要求较高的应用场景,例如需要与用户交互的程序,良好的响应速度能提升用户体验。 Parallel 更适合那些对整体吞吐量要求较高的场景,尽快完成程序的运算任务,例如后台运算而不需要太多交互
CMS
Concurrent Mark Sweep,是一种以低停顿时间为目标的垃圾收集器,其最大特点是不会 STW,而是通过并发的方式来进行标记和清除,以尽量减少应用程序的停顿时间。主要用于老年代,采用标记-清除算法。
四个步骤:
- 初始标记:标记
GC Roots能直接关联的对象。暂停用户线程 - 并发标记:遍历对象图。与用户线程并发
- 重新标记:修正并发标记阶段产生的标记变动(因用户线程的并发执行)。暂停用户线程
- 并发清除:清理标记死亡的对象。与用户线程并发

CMS 的优点是并发收集,低停顿。但也存在缺点:
- 对处理器资源非常敏感
- 无法处理浮动垃圾
- 容易产生大量内存碎片空间
浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾(产生了新对象),这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,CMS 收集需要预留出一部分内存,不能等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现
Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS,导致很长的停顿时间。
G1
Garbage First,是一种面向服务端应用的垃圾收集器,它的设计目标是在尽量短的停顿时间内达到尽可能高的吞吐量。G1 采用分代收集和区域化的思想,将整个堆空间划分为多个小块(Region),并根据各个区域的垃圾回收效率来动态调整回收策略,优先挑垃圾最多、回收收益最高的 Region 先回收,实现更加均衡和可控的垃圾回收。G1 同时应用于新生代和老年代,采用标记-整理算法,JDK9 以后已经默认使用 G1。
Region:
- 连续的Java堆划分为多个大小相等的独立Region,每个Region根据需要扮演Eden/Survivor/老年代
- Humongous区域专门存储大对象
- G1 跟踪各个Region里面的垃圾堆积的价值大小(价值即回收所获得的空间大小以及回收所需时间的经验值,后台维护一个优先级列表)
四个步骤:
- 初始标记:标记
GC Roots直接关联的对象,并修改TAMS指针以便正确分配对象。暂停用户线程。 - 并发标记:遍历对象图。与用户线程并发。(用SATB原始快照处理引用变化)
- 最终标记:处理并发阶段结束后遗留的少量SATB记录。暂停用户线程。
- 筛选回收:更新Region的统计数据,根据优先级选择Region回收集,移动存活对象并清理旧Region的全部空间。暂停用户线程。

Shenandoah
Shenandoah 是一种由Red Hat开发的低延迟垃圾收集器,旨在降低Java应用程序的停顿时间。它是一种全新的垃圾收集器,旨在解决在大内存堆上的垃圾收集延迟问题。Shenandoah的设计目标是在提供低延迟的同时,保持高吞吐量。特点是支持并发,默认不使用分代收集,改用Connection Matrix全局数据结构来记录跨Region的引用关系
九个步骤:
- 初始标记:标记
GC Roots能直接关联的对象。暂停用户线程 - 并发标记:遍历对象图。与用户线程并发(时间取决于堆中存活对象的数量和对象图的复杂程度)
- 最终标记:处理剩余的SATB扫描,并统计回收价值最高的Region构成回收集。短暂停顿
- 并发清理:清理没有存活对象的Region
- 并发回收:核心特点。借助
读屏障、Brooks Pointers把回收集里的存活对象复制到未被使用的Region - 初始引用更新:把堆中所有指向旧对象的引用修正到复制后的新地址(引用更新)。短暂停顿
- 并发引用更新:真正开始引用更新。与用户线程并发。时间取决于涉及的引用数量
- 最终引用更新:修正存在于GC Roots中的引用。短暂停顿
- 并发清理:回收剩余的回收集中的Region,供以后对象分配使用

ZGC
Z Garbage Collector,由Oracle开发的一种低延迟的垃圾收集器,旨在降低Java应用程序的停顿时间,于JDK 11中首次发布。
传统 GC 停顿的核心原因。垃圾回收的本质是“识别垃圾-回收垃圾-整理内存 ”的过程,而停顿主要源于两个关键环节:
根节点遍历与可达性分析的“全局停顿”:GC 要判断对象是否为垃圾,需从根节点(如线程栈、静态变量、JNI 引用等)出发,遍历整个对象引用图。为避免遍历过程中对象引用关系被动态修改(导致分析结果错误),传统 GC 会暂停所有应用线程(即 STW,Stop-The-World),直到可达性分析完成。内存越大、对象越多,遍历时间越长,停顿就越久。
内存整理的“移动开销”:为解决内存碎片问题,多数 GC 会在回收后整理存活对象(如 G1 的复制算法、CMS 的标记-压缩阶段)。移动对象时不仅要修改对象本身的地址,还要更新所有指向该对象的引用——这个过程同样需要 STW,否则应用线程可能访问到无效地址。
因此,传统 GC 的“停顿”本质是“为了保证内存操作的安全性,牺牲了应用线程的连续性”。而 ZGC,基于 Region 内存布局,使用读屏障、染色指针、虚拟内存多重映射等技术,开创性得将“对象状态管理”从对象本身转移到指针上,实现 GC 线程和应用线程可以并发访问对象,无需通过 STW 来“冻结”引用关系。。
内存布局
ZGC 也使用基于Region内存布局,分大、中、小三类容量:
- 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。
- 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
- 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。
每个大型Region中只会存放一个大对象,这也预示着虽然名字叫作「大型Region」,但它的实际容量完全有可能小于中型Region,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配的,因为复制一个大对象的代价非常高昂。
读屏障
之前的GC都是采用写屏障(Write Barrier)防止 GC 时用户线程读取错误的对象,而ZGC采用的是读屏障。
类似 Spring AOP 的前置通知,当用户线程试图从堆中读取处于重分配集的对象时,会被读屏障拦截,通过转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象。这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而且不需要STW,类似JDK里的CAS自旋,读取的值发现已经失效了,需要重新读取。
Object o = obj.fieldA; // 从堆中读取 Object,会触发读屏障
Object p = o; // 没有从堆中加载,不会触发读屏障
o.doSomething(); // 没有从堆中加载,不会触发读屏障
int i = obj.fieldB // 加载的不是对象,不会触发读屏障由于第一次访问需要查转发表并转发,因此可能变慢,但修正后即可恢复正常,官方称之为指针的「自愈能力」。(对比Shenandoah的Brooks转发指针,每次对象访问都必须付出的固定开销,所以每次都慢)
染色指针
ZGC 出现之前, GC 信息保存在对象头的 Mark Word 中,如对象的哈希码、分代年龄、锁记录等就是这样存储的。但 GC 过程中使用的一些信息,跟对象本身并无直接关系,只跟对象的引用有关,因此 GC 必须先访问对象,才能获得一些元信息用于 GC 算法。
而ZGC的染色指针,直接将这些元信息标记在引用对象的指针上:
- 前 16 bit 预留,留作未来扩展(比如提升最大堆内存大小)
- Finalizable:该位表示对象是否仅通过 finalizer 可达(过时,不用管)
- Remapped:该位表示指针是否已经进行了重映射,即指针不再指向迁移集合(Relocation Set)中的对象。
- Marked0 & Marked1:这两位表示对象是否已被 GC 标记,以及是在哪个周期标记。ZGC 在每个 GC 周期中交替使用这两位,以确定对象是在上个周期亦或当前周期被标记。
- 后 44 bit:引用地址,最高支持 16TB 堆内存
这样可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,大幅减少在垃圾收集过程中内存屏障的使用数量。

然而,JVM 作为一个应用进程,如何将这样魔改的内存地址映射到实际的物理地址呢?ZGC 采用了虚拟内存映射技术来解决这个问题,用 mmap 为 Marked0、Marked1 和 Remapped 分别申请不同的虚拟地址,形成三个内存视图空间,每个对象根据元信息的不同,对应三个虚拟内存地址,并且这三个不同的虚拟内存地址映射到同一块物理内存地址上。

运行过程
- 并发标记:遍历对象图做可达性分析,特殊点在于标记是在指针上而不是在对象上,标记阶段会更新染色指针中的Marked 0 or Marked 1标志位
- 并发预备重分配:这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些 Region,将这些Region组成重分配集(Relocation Set)
- 并发重分配:重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表,记录从旧对象到新对象的转向关系
- 并发重映射:重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用(按照前面讲述的理论,这一步并不是必须的,ZGC 指针能够自愈)
ZGC几乎整个收集过程都全程可并发,只有并发标记和并发重分配需要短暂停顿,且只与GC Roots数量相关而与堆内存大小无关,因而同样实现了任何堆上停顿都小于十毫秒的目标。
但如果分配速率高,将创造大量的新对象,这就产生了大量的浮动垃圾。如果这种高速分配持续维持的话,回收到的内存空间持续小于期间并发产生的浮动垃圾所占的空间,堆中剩余可腾挪的空间就越来越小了。若要从根本上提升ZGC能够应对的对象分配速率,还是需要引入分代收集,让新生对象都在一个专门的区域中创建。所以分代算法有利有弊。
