JVM GC:根节点枚举、安全点、卡表与三色标记
一、根节点枚举
固定可作为 GC Roots 的节点 主要在 全局性的引用 (例如常量或类静态属性)与 执行上下文(例如栈帧中的本地变量表)中。
尽管目标明确,但查找过程要做到高效并不是一件容易的事情。现在 Java 应用越来越庞大,方法区的大小常有数百上千 MB,甚至更大 ,其中的类、常量等数量很多。如果逐个检查来找到所有 GC Roots 节点,必然会非常耗时。
在多数 HotSpot 收集器中,根节点枚举 通常需要暂停用户线程,因为 JVM 需要获取一份关于所有 GC Roots 的一致性快照。如果在分析过程中根节点集合的引用关系仍然不断变化,那么分析结果的准确性就无法保证。
准确式垃圾收集
准确式垃圾收集是指:虚拟机可以知道内存中某个位置的数据具体是什么类型。
例如,内存中有一个
32bit的整数123456,虚拟机能够分辨出它到底是一个指向地址123456的引用,还是一个数值为123456的普通整数。准确分辨哪些内存位置保存的是引用类型,是垃圾收集时判断堆上对象是否仍然可用的前提。
GC 的第一步是找出 根集合,例如:
- 栈上的局部变量;
- 寄存器中的值;
- 静态字段;
- 常量引用。
棘手的是,运行时的栈帧和寄存器里混杂着原始类型(如 int、long)和对象引用(oop),而硬件本身并不会区分它们。
1. 保守式 GC
保守式 GC 会把栈和寄存器中所有"看起来像堆内地址"的值都当作指针。
这会带来两个问题:
- 本该回收的对象被误认为仍然存活,造成内存无法及时释放;
- 无法安全地移动对象,因为 JVM 无法确定某个值到底是指针还是普通整数。
2. 准确式 GC
准确式 GC 必须明确知道:某个位置保存的到底是不是 oop。
这就要求 JIT 编译器在生成代码时保留必要的类型信息,这类信息就可以通过 OopMap 表示。
目前主流 JVM 使用的是准确式垃圾收集。发生 STW 后,HotSpot并不需要逐个检查所有执行上下文和全局引用位置,而是借助 OopMap 快速完成根节点枚举。
具体来说:
- 对于堆中对象,
JVM可以通过类元数据知道对象内部哪些字段是引用类型; - 对于线程栈和寄存器,
JVM需要依赖JIT在安全点生成的OopMap,记录当前栈帧和寄存器中哪些位置保存着oop。
什么是 OopMap?
OopMap是一张记录表。它描述了在某个程序位置(通常是安全点),
CPU的寄存器和当前栈帧中,哪些存储单元保存着指向对象的指针(oop)。
OopMap 可以帮助 GC:
- 准确遍历线程栈的每一个栈帧,找出所有存活引用;
- 安全地移动或回收对象。
二、安全点
在 OopMap 的帮助下,HotSpot 可以快速完成 GC Roots 的枚举。
但是,虽然 OopMap 记录了某一个代码位置上,栈的哪些位置、哪些寄存器里存放的是对象引用 ,JVM 却不可能在每一条机器指令上都维护准确的 OopMap。
因此,JVM 引入了 安全点(Safepoint) 。
安全点是一组被精心挑选出来的代码位置。在这些位置上,JVM 可以安全地挂起所有应用线程,并且能够获得准确的 OopMap。
也就是说,用户程序执行时并不是在任意指令位置都能暂停下来开始垃圾收集,而是必须执行到安全点后,才能安全暂停。
当垃圾回收发生时,JVM 需要让所有线程都运行到最近的安全点,然后停顿下来。常见方案有两种。
1. 抢先式中断
抢先式中断不要求线程主动配合。
当垃圾收集发生时,系统先把所有用户线程全部中断。如果发现某个用户线程中断的位置不在安全点上,就恢复这条线程,让它继续执行一小段时间,然后再次中断,直到它运行到安全点为止。
2. 主动式中断
主动式中断不会直接中断线程,而是设置一个中断标志。
各个线程在执行过程中会不断轮询这个标志。一旦发现中断标志为真,就主动运行到最近的安全点并挂起。
三、安全区域
安全点机制保证了程序在正常执行时,可以在较短时间内遇到可进入垃圾收集过程的安全点。
但是,对于处于 Sleep 或 Blocked 状态的线程来说,它们无法响应 JVM 的中断请求,也无法继续执行到最近的安全点。JVM 又不可能一直等待这些线程重新被调度。
为了解决这个问题,JVM 引入了 安全区域(Safe Region) 。
安全区域是指:在某一段代码片段中,对象引用关系不会发生变化。因此,在这段区域内的任意位置开始垃圾收集都是安全的。
也可以把安全区域理解为 被扩展、拉伸后的安全点。
1. 线程进入安全区域
当线程准备执行一段不会改变对象引用关系的代码时,例如调用 Thread.sleep(),它会先标记自己已经进入安全区域。
标记之后,线程就可以继续执行后续可能长时间阻塞的代码,不再参与安全点轮询。
2. JVM 发起 GC 时的处理
当 JVM 需要 STW 时,会检查所有 Java 线程的状态:
- 对于正在运行的线程,JVM 会设置标志,让它们自己运行到最近的安全点并挂起;
- 对于已经标记处于安全区域的线程,JVM 会直接把它们视为已经处于可
GC状态,不再等待它们。
这样,即使线程正在 deep sleep,GC 也可以正常开始。
3. 线程离开安全区域
当阻塞结束,线程要回到正常的 Java 代码执行之前,必须先尝试离开安全区域。
此时线程会检查 JVM 是否已经完成了 STW 操作,例如根节点枚举是否结束:
- 如果
GC已经完成,线程就清除安全区域标记,继续执行; - 如果
GC尚未完成,线程必须主动阻塞等待,直到收到 JVM 发出的"可以离开"的信号,才能继续执行。
这样可以保证线程在离开安全区域后、修改对象引用关系之前,不会与正在进行的 GC 产生冲突。
四、记忆集和卡表
在分代理论中,为了解决对象跨代引用问题,并避免 Minor GC 扫描整个老年代,JVM 需要维护 老年代到新生代的跨代引用信息。
这个抽象结构叫 记忆集(Remembered Set) 。
HotSpot 中常见的记忆集实现是 卡表(Card Table) 。
1. 什么是记忆集
记忆集是一种用于 记录从非收集区域指向收集区域的指针集合 的抽象数据结构。
如果不考虑效率和成本,最简单的实现可以用一个数组记录所有含有跨代引用的对象:
vbnet
class RememberedSet {
Object[] set = new Object[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
}
但是,这种记录全部跨代引用对象的方案不仅占用空间,而且维护成本很高。
在垃圾收集场景中,收集器通常只需要判断:
某一块非收集区域中是否存在指向收集区域的指针?
它并不一定需要知道这些跨代指针的全部细节。
因此,记忆集通常会采用更粗粒度的实现方式,以节省存储和维护成本。
2. 记忆集的记录精度
常见的记录精度有三种:
| 精度 | 含义 | 特点 |
|---|---|---|
| 字长精度 | 每个记录精确到一个机器字长 | 精度高,但维护成本大 |
| 对象精度 | 每个记录精确到一个对象 | 可以知道哪个对象含有跨代指针 |
| 卡精度 | 每个记录精确到一块内存区域 | 精度较粗,但维护成本较低 |
其中,卡精度 就是通过 卡表 来实现记忆集。
3. 什么是卡表
卡表是记忆集的一种具体实现。
它最简单的形式是一个字节数组,HotSpot 中常见实现也是类似思路。
ini
CARD_TABLE[this_address >> 9] = 0;
字节数组 CARD_TABLE 的每一个元素都对应堆内存中的一块特定大小的内存区域,这块内存区域称为 卡页(Card Page) 。
一个卡页中通常包含不止一个对象。
只要卡页内有一个对象的字段存在跨代指针,就将对应卡表元素标记为 1,称为这个卡页 变脏(Dirty) ;如果没有跨代指针,则标记为 0。
发生垃圾收集时,只需要筛选出卡表中变脏的元素,就能知道哪些卡页可能包含跨代指针,并把这些卡页加入 GC Roots 的扫描范围。
4. 示例
假设堆中有以下对象:
lua
老年代:
+-----------------------------+
| oldObj1 |
| oldObj2 -----> youngObjA |
| oldObj3 |
| oldObj4 -----> youngObjB |
+-----------------------------+
新生代:
+-----------------------------+
| youngObjA |
| youngObjB |
| youngObjC |
+-----------------------------+
如果没有卡表,Minor GC 需要扫描整个老年代:
oldObj1
oldObj2
oldObj3
oldObj4
有了卡表之后,可能只需要扫描:
oldObj2 所在 Card
oldObj4 所在 Card
这样就大大降低了 GC 的扫描负担。
五、写屏障
上面介绍了记忆集如何缩小 GC Roots 的扫描范围,但还没有解决一个问题:
卡表元素应该如何维护?
卡表元素变脏的时机很明确:当其他分代区域中的对象引用了本区域对象时,其对应的卡表就应该变脏。
这个变脏动作原则上应该发生在 引用赋值 的那一刻。
在解释执行字节码的场景中,这相对容易处理,因为虚拟机负责每条字节码指令的执行,有充分的介入空间。
但在编译执行场景中,经过 JIT 编译后,代码已经变成了机器指令流。此时就必须找到一种机器码层面的手段,把维护卡表的动作插入到引用赋值操作中。
这个机制就是 写屏障(Write Barrier) 。
1. 写屏障的作用
在 HotSpot 中,卡表状态通常通过写屏障维护。
写屏障可以看作虚拟机层面对"引用类型字段赋值"动作的一个 AOP 切面。
在引用赋值前后的额外逻辑中,JVM 可以完成一些辅助动作:
- 写前屏障:在引用赋值之前执行;
- 写后屏障:在引用赋值之后执行。
维护卡表通常使用的是 写后屏障。
2. 简化示例
scss
void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值操作
*field = new_value;
// 写后屏障:在这里完成卡表状态更新
post_write_barrier(field, new_value);
}
应用写屏障后,虚拟机会为引用赋值操作生成相应的屏障指令。
一旦收集器在写屏障中增加了更新卡表的操作,那么只要引用发生更新,就会产生额外开销。这个开销通常远小于 Minor GC 扫描整个老年代的代价。
3. 伪共享问题
卡表底层通常是 byte 数组。
如果多个线程频繁修改相邻的 Card Table 字节,可能会影响 CPU 缓存,这就是经典的 伪共享问题。
一种优化方式是:在修改卡表之前,先检查卡表状态。如果对应卡页已经是脏的,就没有必要再次修改。
六、并发的可达性分析
可达性分析算法理论上要求整个分析过程基于一个一致性快照。
这意味着,如果要保证绝对简单的一致性,就需要全程冻结用户线程的运行。
但是,根节点枚举阶段由于 GC Roots 相比整个 Java 堆中的对象数量少得多,并且有 OopMap 等优化手段支持,所以它带来的停顿通常较短,而且相对固定。
真正耗时的是从 GC Roots 继续向下遍历对象图的过程。
这一步的停顿时间往往与 Java 堆容量直接相关:
- 堆越大;
- 存活对象越多;
- 对象图结构越复杂;
标记阶段产生的停顿时间就可能越长。
因此,现代低延迟垃圾收集器通常会尽量把耗时较长的对象图遍历阶段并发化,以减少 STW 时间。
七、三色标记法
如果所有应用线程都暂停,那么简单的两色标记也可以工作。
但是,为了降低 STW 停顿时间,现代 GC 往往会进行 并发标记:
GC 线程在标记对象;
应用线程也在同时运行;
应用线程可能修改对象引用关系。
此时,GC 正在扫描对象图,而用户线程可能突然修改引用关系,这就可能导致 GC 漏标一个本来应该存活的对象。
在并发 GC 中,不能只用两色标记法的核心原因是:
两色只能表示"是否访问过",但无法表示"访问到了但还没扫描完"这个中间状态。
三色标记法会把遍历对象图过程中遇到的对象,按照访问状态分成三类。
1. 白色对象
白色表示对象 尚未被垃圾收集器访问过。
在可达性分析刚开始时,所有对象都是白色的。
如果在分析结束时对象仍然是白色,则说明该对象不可达,可以被回收。
2. 黑色对象
黑色表示对象 已经被垃圾收集器访问过,并且这个对象的所有引用都已经扫描过。
黑色对象代表已经扫描完成,是安全存活的对象。
如果有其他对象引用指向黑色对象,通常不需要重新扫描这个黑色对象。
在正确的三色标记过程中,需要维护一个重要不变式:
黑色对象不能直接指向白色对象。
否则,如果黑色对象已经扫描完成,GC 后续不会再扫描它,那么它直接引用的白色对象就可能被漏标。
3. 灰色对象
灰色表示对象 已经被垃圾收集器访问过,但这个对象上至少还有一个引用没有被扫描过。
灰色对象可以理解为对象图遍历过程中的中间状态。
八、并发标记中的漏标问题
三色标记法中最重要的问题是:
在并发
GC下,如何避免一个本来存活的对象被错误地当成垃圾回收?
漏标发生通常需要同时满足两个条件:
- 黑色对象新增了对白色对象的引用;
- 灰色对象到这个白色对象的引用断开。
典型结构可以理解为:
黑色对象新增对白色对象的引用;
灰色对象删除对白色对象的引用。
由于黑色对象已经扫描完成,GC 不会再回头扫描它。
同时,灰色对象到白色对象的引用又被用户线程断开。
这样一来,这个白色对象虽然仍然被黑色对象引用着,却可能没有机会再被 GC 扫描到,于是发生漏标。
九、增量更新与原始快照
要解决并发扫描时的漏标问题,只需要破坏两个必要条件中的任意一个。
常见方案有两种:
- 增量更新;
- 原始快照。
1. 增量更新
增量更新破坏的是第一个条件:黑色对象新增对白色对象的引用。
当黑色对象新增对白色对象的引用时,写屏障会把这个新增引用记录下来,或者把相关对象重新标记为灰色。
这样后续 GC 就有机会重新处理这部分对象,避免白色对象被漏标。
增量更新通常依赖 写后屏障。
ini
obj.field = newObj; // 写入新值
record(newObj); // 写后记录新值
2. 原始快照
原始快照破坏的是第二个条件:灰色对象到白色对象的引用断开。
它的思想是:以 GC 开始那一刻的对象引用图为准。
当引用被删除时,写屏障会把旧引用记录下来。即使这个对象后来在并发标记过程中已经变成垃圾,只要它在 GC 开始时是存活的,本轮就先不回收,等下一轮再处理。
这可能产生 浮动垃圾,但可以避免错误回收仍然存活的对象。
原始快照通常依赖 写前屏障。
ini
old = obj.field; // 写前记录旧值
obj.field = newObj;
无论是增量更新还是原始快照,本质上都依赖写屏障来让 GC 感知用户线程对对象引用关系的修改。
十、总结
这几个机制可以串成一条完整链路:
GC Roots 枚举
↓
准确式 GC
↓
OopMap
↓
安全点 / 安全区域
↓
记忆集 / 卡表
↓
写屏障
↓
并发可达性分析
↓
三色标记
↓
增量更新 / 原始快照
它们分别解决的问题是:
| 机制 | 解决的问题 |
|---|---|
OopMap |
如何快速、准确地知道栈和寄存器中哪些位置是对象引用 |
| 安全点 | JVM 应该在什么位置暂停线程才是安全的 |
| 安全区域 | 线程阻塞时无法到达安全点怎么办 |
| 记忆集 | 如何避免收集一个区域时扫描整个堆 |
| 卡表 | 如何用较低成本实现记忆集 |
| 写屏障 | 如何在引用变化时维护卡表或标记信息 |
| 三色标记 | 并发标记时如何描述对象扫描状态 |
| 增量更新 / 原始快照 | 并发标记时如何避免漏标 |
整体来看,这些机制本质上都在解决同一个问题:
在尽可能减少停顿时间的前提下,保证垃圾收集结果的正确性。