相关问题

  1. 什么是 GC?Java 为什么仍然可能发生内存泄漏?
  2. JVM 怎么判断一个对象是否可以回收?
    • 引用计数法
    • 可达性分析
    • GC Roots
  3. 常见的 GC Roots 有哪些?
  4. 什么是三色标记法?并发标记为什么会有漏标和多标问题?
  5. Java 的强引用、软引用、弱引用、虚引用有什么区别?
  6. 常见垃圾回收算法有哪些?
    • 标记-清除
    • 复制
    • 标记-整理
  7. 为什么 JVM 要采用分代收集?
  8. 新生代和老年代分别存什么对象?为什么适合不同的回收算法?
  9. Eden、Survivor 0、Survivor 1 是怎么配合工作的?
  10. 对象什么时候会进入老年代?
  11. Young GC、Major GC、Full GC 有什么区别?
  12. 哪些情况可能触发 Full GC?为什么 Full GC 通常比较慢?
  13. 什么是 STW?为什么 GC 过程中需要暂停业务线程?
  14. 什么是 Safepoint?它和 STW 有什么关系?
  15. 什么是 Card Table / Remembered Set?为什么 Young GC 不能直接扫描整个老年代?
  16. CMS 的工作流程是什么?它有哪些优缺点?
  17. G1 的核心设计是什么?
  • Region
  • Young GC
  • Mixed GC
  • 为什么叫 Garbage First
  • 为什么能控制停顿时间
  1. ZGC 的核心特点是什么?为什么能做到低停顿?
  2. G1 和 ZGC 怎么选?
  3. 线上 GC 问题怎么排查?
  • Young GC 频繁
  • Full GC 频繁
  • GC 后内存降不下来
  • Heap Dump
  • 常用工具和指标

JVM 怎么判断对象是否可以回收

垃圾回收的第一步,是先判断哪些对象已经没有用了。

一种直观的思路是引用计数法:每有一个地方引用对象,计数就加一;引用消失就减一;当计数变成 0,就认为这个对象可以回收。

但引用计数处理不了循环引用。

例如:

1
2
a.b = b;
b.a = a;

即使外部已经没有任何地方再使用 a 和 b,它们仍然互相引用,所以引用计数不会变成 0。

因此 Java 主要采用的是可达性分析。

JVM 会从一组称为 GC Roots 的对象出发,沿着对象之间的引用关系不断向下查找。

如果一个对象能够从 GC Roots 出发被找到,就说明它仍然可能被程序使用,不能回收;如果已经无法从任何 GC Roots 到达,就认为它处于不可达状态,可以进入垃圾回收流程。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
GC Roots
   ↓
对象 A
   ↓
对象 B

A、B 可达
→ 不能回收


对象 C

无法从 GC Roots 到达
→ 可以回收

所以 JVM 判断对象是否可以回收,核心是看它还能不能从 GC Roots 出发被找到。

这也意味着,即使两个对象互相引用,只要它们整体已经和 GC Roots 断开,依然可以被回收。

常见的 GC Roots 主要包括:

  • 虚拟机栈中的引用,比如当前方法中的局部变量引用
  • 类的静态字段引用的对象
  • JNI / Native 方法持有的对象引用
  • JVM 内部仍然存活的一些对象,比如正在运行的线程、类加载器等

三色标记法

可达性分析确定了 GC 要从 GC Roots 出发,沿着对象之间的引用关系判断哪些对象仍然存活。

但在真正遍历对象引用图时,JVM 还需要记录哪些对象已经扫描过,哪些对象只是刚刚被发现。

三色标记法就是一种常见的扫描方式。

它把对象分成三种状态:

1
2
3
4
5
6
7
8
9
白色
→ 还没有被扫描到

灰色
→ 当前对象已经被发现
→ 但它引用的对象还没有全部扫描

黑色
→ 当前对象以及它引用的对象都已经扫描完成

GC 开始时,可以先把对象看成白色,然后从 GC Roots 开始扫描。

当某个对象被找到以后,它会从白色变成灰色;接着 GC 会继续扫描这个灰色对象引用的其他对象,把新发现的对象也变成灰色。

当一个灰色对象引用的其他对象都扫描完成以后,它自己就会变成黑色。

整个过程会不断重复,直到已经没有灰色对象需要继续处理。

这时候仍然保持白色的对象,就说明它们没有从 GC Roots 出发被找到,也就是不可达对象,可以进入回收流程。

所以三色标记法本质上就是:

用白、灰、黑三种状态记录可达性分析过程中对象的扫描进度。

三色标记在并发 GC 中尤其重要,因为 GC 扫描对象的同时,业务线程还可能继续修改对象之间的引用关系,这时就可能出现漏标或多标的问题,后面的写屏障、SATB、增量更新等机制,都是围绕这个问题展开的。

三色标记法的问题

如果 GC 标记期间业务线程完全停止,那么三色标记本身并没有太大问题。

真正麻烦的是并发 GC:GC 在扫描对象的同时,业务线程还在继续运行,也可能不断修改对象之间的引用关系。

这时可能出现多标和漏标。

多标指的是某个对象在 GC 扫描时还是可达的,所以被标记成存活,但业务线程随后删除了这条引用。

这个对象实际上已经变成垃圾,但本轮 GC 仍然会把它保留下来。

这种情况影响不大,因为最多只是晚一次 GC 再回收。

更危险的是漏标。

假设当前有:

A(黑) ↓ B(灰) ↓ C(白)

此时业务线程做了两件事:

A 新增了对 C 的引用 B 删除了对 C 的引用

于是引用关系变成:

A(黑) → C(白)

B(灰)

A 已经是黑色对象,说明 GC 已经扫描完它,通常不会再重新检查它的新引用。

而 B 之后被扫描时,也已经不再引用 C。

这样 C 虽然实际上仍然被 A 引用,却可能一直保持白色,最后被 GC 当成垃圾回收。

这就是漏标。

Java 的四种引用

Java 里的引用分为强引用、软引用、弱引用和虚引用,它们的区别主要在于:对对象存活的保护能力不同。

强引用就是平时最常见的引用:

1
User user = new User();

只要对象还能通过强引用从 GC Roots 到达,GC 就不会回收它。

软引用的保护能力弱一些。对象只剩软引用时,通常会在内存比较紧张的时候被回收,比较适合描述“有内存就留着,没内存可以丢”的对象。

弱引用更弱。对象如果只剩弱引用,那么发生 GC 时就可以被回收。前面 ThreadLocalMap 的 key 使用的就是弱引用。

虚引用最弱,它基本不能用来正常访问对象,主要用于跟踪对象的回收过程,通常会配合 ReferenceQueue 使用。

所以从“对象有多难被 GC 回收”来看,可以理解成:

1
2
3
4
5
6
7
强引用
↓
软引用
↓
弱引用
↓
虚引用

引用越弱,对对象存活的保护能力就越低。

垃圾回收算法

JVM 判断出哪些对象已经不可达以后,接下来就要真正回收这些对象占用的内存。

常见的垃圾回收算法主要有三种:标记-清除、复制、标记-整理。

标记-清除最直接。先通过可达性分析把存活对象标记出来,然后把没有被标记的对象清掉。

它的问题是,清理完以后内存里会留下很多不连续的小块空间,也就是内存碎片。后面即使总空闲空间很多,也可能因为找不到一块足够大的连续空间而分配失败。

复制算法会把内存分成两块,每次只使用其中一块。GC 时,把还活着的对象复制到另一块,然后把原来的整块空间直接清空。

这种方式不会产生大量内存碎片,而且对象分配也比较快,但缺点是需要预留一部分额外空间。

标记-整理和标记-清除类似,也会先标记存活对象。不同的是,它不会只是把垃圾对象清掉,而是把存活对象向内存的一端移动,最后把剩余空间整体清理掉。

这样可以避免内存碎片,但移动对象本身会带来额外成本。

所以这三种算法本质上是在不同问题之间做取舍:

标记-清除实现简单,但容易产生碎片;复制算法分配快、碎片少,但需要额外空间;标记-整理能保留连续空间,但需要移动对象。

实际 JVM 不会只使用一种算法,而是会根据不同区域中对象的存活特点选择不同策略。比如对象存活率低的区域更适合复制,存活率高的区域则更适合标记-整理。

分代收集

因为不同对象的生命周期差别很大,所以JVM会把堆划分成不同区域

实际程序里,大部分对象都是“朝生夕死”的,比如一次请求中临时创建的 DTO、集合、字符串等,用完以后很快就失去引用;但也有一部分对象会长期存活,比如缓存、单例、线程池相关对象等。

如果把所有对象都放在一起,每次 GC 都统一扫描和处理,效率会比较低。

所以 JVM 会按照对象的存活特征,把堆划分成不同代。

1
2
3
4
5
6
7
堆

├── 新生代
│   └── 主要放刚创建、生命周期较短的对象
│
└── 老年代
    └── 主要放存活时间较长的对象

新生代中的对象存活率通常比较低,所以更适合使用复制算法:只需要复制少量还活着的对象,剩下的空间可以直接清理。

老年代中的对象存活率通常比较高,如果还使用复制算法,就需要搬动大量对象,成本会比较高,因此更适合标记-整理这类算法。

分代收集

在传统的分代收集模型中,Java 堆通常会划分为新生代和老年代。

新生代主要存放刚创建、生命周期较短的对象;老年代主要存放经过多次 GC 后仍然存活,或者本身生命周期较长的对象。

新生代通常会进一步划分成三个区域:

1
2
3
Eden
Survivor 0
Survivor 1

大多数新创建的对象,最开始都会进入 Eden 区。

当 Eden 空间不足时,会触发一次 Young GC。GC 会检查 Eden 和当前正在使用的 Survivor 区,把仍然存活的对象复制到另一个空的 Survivor 区中。

例如一开始:

1
2
3
4
5
6
7
8
Eden
→ 新创建的对象

S0
→ 空

S1
→ 空

第一次 Young GC 以后,Eden 中还活着的对象会被复制到 S0:

1
2
3
4
5
6
7
8
Eden
→ 清空

S0
→ 存活对象

S1
→ 空

之后程序继续运行,新对象又进入 Eden。

下一次 Young GC 时,会同时检查 Eden 和 S0,把仍然存活的对象复制到 S1:

1
2
3
4
5
6
7
8
Eden + S0
→ 扫描存活对象
→ 复制到 S1

然后:
Eden 清空
S0 清空
S1 成为新的 Survivor 区

再下一次 GC 时,S0 和 S1 的角色再交换。

所以两个 Survivor 区并不是同时拿来存对象,而是轮流使用:

1
2
3
4
5
6
7
8
Eden
→ 放新对象

一个 Survivor
→ 保存上一次 GC 后的幸存对象

另一个 Survivor
→ 作为下一次复制的目标区域

这样设计的原因,就是为了配合复制算法:新生代里大多数对象都会很快死亡,因此每次只需要把少量幸存对象复制到另一个 Survivor 区,剩下的 Eden 和旧 Survivor 就可以整体清空。

对象什么时候进入老年代

对象在新生代中每经历一次 Young GC 仍然存活,年龄通常就会增加 1。

当年龄达到一定阈值以后,就可能晋升到老年代。经典 HotSpot 中,这个阈值可以通过:

1
-XX:MaxTenuringThreshold

进行控制。

很多经典资料里常见的最大年龄是 15,但实际晋升并不只是简单看“活了多少次”。

如果 Survivor 区放不下,或者对象满足动态年龄判断等条件,也可能提前进入老年代。

所以对象进入老年代,大致可以理解成几种情况:

1
2
3
4
在 Survivor 中存活足够久
Survivor 空间不足
满足动态年龄判断
某些大对象直接进入老年代

老年代怎么回收

老年代中的对象通常已经经历过多次 GC,存活率比较高。

如果继续使用复制算法,就需要搬动大量仍然存活的对象,成本会比较高,所以老年代更适合使用标记-清除或者标记-整理这类算法。

其中标记-清除不需要移动大量对象,但容易产生内存碎片;标记-整理会把存活对象向一端移动,能够得到连续的空闲空间,但整理过程本身成本更高。

Stop-The-World

GC无法避免的一个地方就是Stop-The-World,简称 STW,指的是 JVM 在执行某些 GC 操作时,需要暂时停止业务线程,只让 GC 线程工作。

之所以需要暂停业务线程,是因为 GC 在判断对象引用关系、移动对象或者整理内存时,如果业务线程还在不断修改对象之间的引用,GC 看到的对象关系就可能一直变化,导致标记结果不一致。

所以 JVM 会在某些关键阶段把业务线程暂停下来,等这一阶段处理完成以后再继续运行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
业务线程正常运行
    ↓
进入 GC 某个关键阶段
    ↓
暂停业务线程
    ↓
GC 执行标记 / 整理等操作
    ↓
GC 阶段结束
    ↓
业务线程继续运行

STW 最大的问题就是会直接造成程序停顿。

如果一次 STW 持续几十毫秒,通常影响不大;但如果持续几百毫秒甚至几秒,用户就可能明显感觉到接口卡顿、请求延迟升高。

所以现代垃圾收集器一个很重要的目标,就是:

尽量减少 STW 的次数和持续时间,把更多 GC 工作放到和业务线程并发执行的阶段中。

CMS、G1、ZGC 等收集器的很多设计,本质上都在围绕这个目标做优化。

CMS

CMS,全称 Concurrent Mark Sweep,是一种以降低停顿时间为目标的老年代垃圾收集器。

它的核心思路是:尽量让 GC 和业务线程同时执行,只在少数关键阶段暂停业务线程。

CMS 的回收过程主要分成四个阶段。

首先是初始标记。

这一阶段只标记 GC Roots 能直接关联到的对象,工作量比较小,但需要 STW。

接着进入并发标记。

GC 会从刚刚找到的对象继续向下遍历整个引用关系,找出老年代中仍然存活的对象。这个阶段耗时比较长,但可以和业务线程同时执行。

然后是重新标记。

因为并发标记期间业务线程还在运行,对象之间的引用关系可能已经发生变化,所以 CMS 需要再做一次修正,把并发阶段产生的变化补回来。

这个阶段同样需要 STW,但通常比完整标记整个老年代要快很多。

最后进入并发清除。

CMS 根据前面的标记结果,把已经死亡的对象清理掉。这个阶段也可以和业务线程并发执行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
初始标记
→ STW,快速找到 GC Roots 直接关联的对象

并发标记
→ GC 和业务线程一起运行
→ 完成主要的可达性分析

重新标记
→ STW
→ 修正并发期间发生变化的引用关系

并发清除
→ GC 和业务线程一起运行
→ 清理垃圾对象

CMS 的优势就是停顿时间比较短,因为最耗时的标记和清除阶段都尽量和业务线程并发执行。

但它也有几个比较明显的问题。

CMS 采用的主要是标记-清除算法,所以对象被清理以后容易产生内存碎片。碎片太多时,即使老年代总空闲空间还够,也可能找不到一块足够大的连续空间。

另外,并发标记和并发清除期间,业务线程还在继续创建对象,所以 GC 过程中还会不断产生新的垃圾,这些垃圾通常只能等下一次 GC 再处理,也就是所谓的浮动垃圾。

同时,GC 线程和业务线程一起运行,也会占用一部分 CPU,导致业务吞吐量下降。

所以 CMS 可以理解成一种典型的取舍:

用更多 CPU 和一定的内存碎片,换取更短的 GC 停顿时间。

G1

G1,全称 Garbage First,是一种面向整个 Java 堆的垃圾收集器。

它出现的一个重要目的,就是在大堆场景下尽量控制 GC 停顿时间,同时解决 CMS 容易产生内存碎片的问题。

G1 和传统分代收集器最大的不同,是它不再把堆简单划成一整块连续的新生代和一整块连续的老年代,而是把整个堆切成很多大小相同的 Region。

1
2
3
4
5
6
7
Java Heap

┌──────┬──────┬──────┬──────┐
│ Eden │ Old  │ Eden │ Old  │
├──────┼──────┼──────┼──────┤
│ S    │ Old  │ Free │ Eden │
└──────┴──────┴──────┴──────┘

每个 Region 在不同时间可以承担不同角色,比如 Eden、Survivor、Old。

所以 G1 仍然有新生代和老年代的概念,但它们不再要求物理上连续,而是由很多 Region 组合起来。

这样设计以后,G1 就可以更灵活地选择哪些区域值得优先回收。

这也是 Garbage First 名字的来源:

优先回收垃圾比例高、回收收益大的 Region。

G1 主要有两类比较重要的回收。

Young GC 主要回收年轻代 Region。存活对象会被复制到新的 Survivor Region,或者晋升到 Old Region。

当老年代占用逐渐升高以后,G1 会开始进行并发标记,找出各个 Region 中垃圾对象的大致分布。

之后就可以进入 Mixed GC。

Mixed GC 不只是回收年轻代,同时还会挑选一部分垃圾比例比较高的老年代 Region 一起回收。

所以可以理解成:

1
2
3
4
5
6
Young GC
→ 回收年轻代

Mixed GC
→ 回收年轻代
+ 一部分值得回收的老年代 Region

G1 在回收 Region 时,会把存活对象复制到其他 Region,因此天然带有整理内存的效果,不容易像 CMS 那样长期留下大量内存碎片。

另外,G1 还支持设置期望最大停顿时间,例如:

1
-XX:MaxGCPauseMillis

G1 会根据每个 Region 的垃圾量、回收成本等信息,估算一次能够回收多少 Region,尽量把停顿控制在目标范围附近。

所以 G1 的核心设计可以概括成:

把堆拆成很多 Region,统计每个 Region 的回收收益,优先选择最值得回收的区域,从而在回收效率和停顿时间之间做更灵活的控制。

G1 的垃圾回收过程

G1 的回收过程并不是一次性把所有工作都在 STW 中完成,而是把工作拆成多个阶段。

比较重要的流程可以分成:

Young GC、并发标记、Remark、Cleanup、Mixed GC。

Young GC 主要回收年轻代 Region。

这个阶段需要 STW。G1 会暂停业务线程,把 Eden 中仍然存活的对象复制到 Survivor Region,或者直接晋升到 Old Region,然后回收原来的年轻代 Region。

当老年代占用达到一定程度以后,G1 会启动并发标记周期。

首先会进行初始标记,主要标记从 GC Roots 直接可达的对象。这个阶段需要 STW,不过通常会和 Young GC 一起完成,所以停顿时间相对较短。

接下来进入并发标记。

GC 线程会和业务线程同时运行,从已经标记的对象继续向下遍历整个对象引用关系,统计各个 Region 中有哪些对象仍然存活。

这一阶段不需要长时间 STW,也是 G1 能降低停顿的重要原因之一。

并发标记结束以后,会进行 Remark,也就是重新标记。

因为并发标记期间业务线程还在运行,对象引用关系可能发生变化,所以需要做一次最终修正。

Remark 需要 STW。

之后进入 Cleanup,根据前面的标记结果统计各个 Region 的存活对象和垃圾比例,为后续选择哪些 Region 更值得回收做准备。这个阶段只有部分工作需要停顿,很多统计和整理工作可以并发完成。

完成并发标记周期以后,G1 就知道哪些 Old Region 中垃圾比较多。

接下来进行 Mixed GC。

Mixed GC 同样需要 STW,它会回收年轻代 Region,同时选择一部分垃圾比例较高的 Old Region 一起回收。

存活对象会被复制到其他 Region,因此回收完成以后,原来的 Region 可以整体释放。

所以整个过程可以理解成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
Young GC
→ STW

初始标记
→ STW

并发标记
→ 和业务线程并发

Remark
→ STW

Cleanup
→ 部分停顿 + 部分并发

Mixed GC
→ STW

G1 的核心优化并不是“完全没有 STW”,而是:

把最耗时的全堆标记工作尽量放到并发阶段,只在真正需要保证对象关系稳定、搬迁对象时暂停业务线程。

Young GC、Major GC 和 Full GC

Young GC 主要回收新生代。

当 Eden 空间不足、无法继续分配新对象时,通常会触发 Young GC。GC 会清理新生代中的垃圾对象,把仍然存活的对象复制到 Survivor 区,或者在满足条件时晋升到老年代。

Young GC 一般发生得比较频繁,但因为新生代空间相对较小,而且大部分对象很快死亡,所以单次停顿通常比较短。

Major GC 一般指针对老年代进行的垃圾回收。

不过这个术语在不同垃圾收集器和资料里的定义并不完全统一,有些地方会把老年代 GC 称为 Major GC,有些地方会直接和 Full GC 混用,所以面试里最好先说明自己采用的是“Major GC 主要指老年代回收”这个定义。

Full GC 则表示一次范围更大的垃圾回收,通常会涉及整个堆,很多情况下还会同时处理方法区或类元数据相关内容。

Full GC 的停顿通常比 Young GC 更长,因此线上最需要重点关注的往往就是 Full GC 的频率和停顿时间。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Young GC
→ 主要回收新生代
→ 频率高、单次通常较快

Major GC
→ 主要回收老年代
→ 术语定义在不同资料中可能有差异

Full GC
→ 回收范围更大,通常涉及整个堆
→ 停顿通常更长

常见的 Full GC 触发原因包括老年代空间不足、元空间不足、显式调用 System.gc(),以及某些垃圾收集器在并发回收失败后的退化处理。

所以实际排查 GC 问题时,Young GC 频繁通常要看对象创建速度和新生代大小,而 Full GC 频繁则更需要关注老年代增长、内存泄漏、元空间占用以及回收器是否出现退化。

GC 问题怎么排查

线上出现接口突然变慢、CPU 升高、频繁卡顿或者 OOM 时,通常都需要结合 GC 情况一起排查。

第一步先看 GC 是否异常,重点关注几个指标:

1
2
3
4
5
6
Young GC 是否过于频繁
Full GC 是否频繁
单次 GC 停顿时间
老年代占用是否持续升高
对象晋升到老年代的速度
堆使用率在 GC 后能不能明显下降

如果 Young GC 很频繁,通常说明新对象创建速度很快,或者新生代空间比较小。需要继续看是不是有大量临时对象、对象分配过快,或者新生代配置不合理。

如果 Full GC 很频繁,就要重点关注老年代。

比较典型的情况是:对象不断进入老年代,但 GC 后老年代占用并没有明显下降。这时候就要怀疑是否存在长期存活对象过多,甚至内存泄漏。

1
2
3
4
5
GC 前老年代 90%
→ Full GC
→ GC 后仍然 85%

并且之后继续上涨

这种情况就比较危险。

如果 GC 后内存能够明显下降,但过一段时间又迅速涨满,那么更可能是对象创建速度过快或者堆容量不足,而不一定是内存泄漏。

如果出现 OOM,通常会生成或者主动导出 Heap Dump,然后分析堆中到底有哪些对象占用了大量内存,以及它们为什么还被 GC Roots 引用。

常见工具包括:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
jstat
→ 查看 GC 次数、各代容量和使用情况

jmap
→ 查看堆信息、生成 Heap Dump

jstack
→ 查看线程状态

jcmd
→ 综合诊断 JVM

MAT / VisualVM
→ 分析 Heap Dump 和对象引用链

所以 GC 排查的整体思路可以归纳为:

1
2
3
4
5
6
先看 GC 次数和停顿
→ 判断问题主要在新生代还是老年代
→ 看 GC 后内存是否能下降
→ 如果持续降不下来,检查长期存活对象
→ 必要时导出 Heap Dump
→ 沿 GC Roots 引用链定位对象为什么没有被回收

真正需要关注的不是“GC 次数越少越好”,而是系统的吞吐、停顿时间和内存增长是否处于合理范围。

ZGC

ZGC 是一种以超低停顿为主要目标的垃圾收集器,特别适合大堆和对延迟敏感的场景。

它和传统 GC 最大的区别,不只是“并发程度更高”,而是连对象重定位这件事也尽量和业务线程并发完成。

传统垃圾收集器在移动对象时,往往需要暂停业务线程,因为对象地址一旦发生变化,其他线程手里的旧引用就可能失效。

ZGC 为了解决这个问题,引入了着色指针和读屏障。

所谓着色指针,可以理解成:对象引用本身除了保存对象地址之外,还会携带一些和 GC 状态有关的信息。ZGC 可以利用这些信息判断这个对象当前是否已经被标记、是否发生过重定位等。

当对象被移动以后,业务线程手里可能还拿着旧引用。这时线程读取这个引用时,会经过读屏障。

读屏障可以发现:

1
2
3
4
这个引用已经过期
→ 对象已经被移动
→ 找到对象新的地址
→ 修正当前引用

所以对象即使正在被 GC 搬迁,业务线程也不需要为了等待所有引用统一更新而长时间停下来。

这也是 ZGC 能够把对象重定位做成并发操作的重要原因。

ZGC 的整体过程可以理解成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
短暂 STW
→ 找到 GC Roots

并发标记
→ 和业务线程一起运行
→ 找出存活对象

短暂 STW
→ 完成一些最终标记处理

并发重定位
→ 把存活对象移动到新的区域
→ 业务线程通过读屏障处理旧引用

因此 ZGC 真正的核心并不是“完全没有 STW”。

它仍然存在少量需要暂停业务线程的阶段,只是这些阶段通常只处理和 GC Roots、线程状态等规模较小的信息,而大量和堆大小相关的扫描、标记、对象移动工作都尽量放到并发阶段完成。

所以即使堆非常大,ZGC 的停顿时间也不会像传统 Full GC 那样随着堆大小明显增加。

和 G1 对比,可以这样理解:

1
2
3
4
5
6
7
8
9
G1
→ 把堆切成 Region
→ 选择一部分 Region 回收
→ 尽量控制每次 STW 的工作量

ZGC
→ 让更多 GC 工作直接和业务线程并发
→ 通过着色指针 + 读屏障解决并发移动对象的问题
→ 更强调极低停顿

因此 ZGC 更适合大堆、低延迟场景,而代价是实现更加复杂,同时读屏障、并发 GC 线程等机制也会带来一定的 CPU 和内存开销。

Safepoint

前面讲 STW 时提到,JVM 在某些 GC 阶段需要暂停业务线程。

但线程并不是在任意一条指令执行到一半时都能直接停下来。JVM 需要等线程运行到一个能够安全检查线程状态、对象引用和栈信息的位置,再进行暂停。

这个位置就是 Safepoint,也就是安全点。

即JVM 预先选好了一些“可以安全暂停线程”的位置。

当 GC 需要进入 STW 时,JVM 会通知各个线程尽快运行到 Safepoint,然后把它们暂停。等需要停顿的 GC 工作完成以后,再恢复这些线程继续运行。

所以 STW 和 Safepoint 的关系是:

1
2
3
4
5
6
JVM 需要 STW
→ 通知业务线程
→ 线程运行到 Safepoint
→ 暂停线程
→ GC 执行关键操作
→ 恢复线程

Safepoint 的存在,本质上是为了让 JVM 能在一个确定、安全的状态下检查线程和对象引用关系,而不是在线程执行到任意位置时强行暂停。

Young GC 为什么还需要关注老年代

Young GC 的目标主要是回收新生代,但新生代对象并不一定只被新生代里的对象引用。

例如:

1
2
3
老年代对象 A
     ↓
新生代对象 B

如果 Young GC 只扫描新生代和 GC Roots,而完全不检查老年代,那么对象 B 可能看起来没有任何引用,从而被误认为是垃圾。

但实际上:

1
A → B

这个引用仍然存在,所以 B 不能被回收。

最直接的解决办法,是每次 Young GC 时把整个老年代也扫描一遍,看看有没有老年代对象引用新生代。

但这样代价太高。

老年代通常比较大,如果每次 Young GC 都扫描整个老年代,就会失去“只回收新生代比较快”的意义。

所以 JVM 需要一种机制,提前记录:

老年代的哪些区域可能存在指向新生代的引用。

Card Table

Card Table 就是为了解决这个问题。

JVM 会把老年代内存划分成很多比较小的区域,每个区域可以理解成一张 Card。

当业务线程修改对象引用时,如果出现:

1
2
老年代对象
→ 指向新生代对象

JVM 会通过写屏障记录这次变化,并把对应的 Card 标记为“脏”。

于是 Young GC 时,就不需要扫描整个老年代,而只需要重点检查这些被标记过的区域。

可以理解成:

1
2
3
4
5
6
7
老年代

Card 1
Card 2   ← 可能引用新生代,标记为脏
Card 3
Card 4   ← 可能引用新生代,标记为脏
Card 5

Young GC 时:

1
2
3
4
5
不扫描整个老年代

只扫描:
Card 2
Card 4

这样就可以大幅减少 Young GC 需要扫描的范围。

所以 Card Table 的核心作用就是:

记录老年代中哪些区域可能存在跨代引用,避免 Young GC 每次都扫描整个老年代。

在 G1 中还会经常看到 Remembered Set。

可以先把它理解成更加细粒度的跨 Region 引用记录机制。G1 会利用 Remembered Set 记录“哪些其他 Region 可能引用了当前 Region”,从而避免为了回收一个 Region 而扫描整个堆。

所以这一块整体可以串成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Young GC 只想回收新生代
        ↓
但老年代可能引用新生代
        ↓
不能完全忽略老年代
        ↓
又不能每次扫描整个老年代
        ↓
Card Table / Remembered Set
        ↓
只记录和扫描可能存在跨代引用的区域

这也是 JVM 能够让 Young GC 保持相对高效的重要原因之一。