三色标记法你可以理解成:GC 在做"根可达性遍历"时,为了记录每个对象的扫描进度,把对象分成白、灰、黑三种状态。
它本质不是新的垃圾判断规则,真正判断对象是否存活的规则还是:
从 GC Roots 出发,能沿引用链找到的对象就是存活对象;找不到的就是垃圾对象。
三色标记法只是把这个"找"的过程变得更适合并发执行。
1. 为什么需要三色标记法?
假设没有并发标记,GC 要判断哪些对象活着,就需要:
- 暂停所有用户线程,也就是 STW;
- 从 GC Roots 开始遍历整个对象引用图;
- 遍历完之后,没被标记的对象就是垃圾。
问题是:堆越大、对象越多,遍历时间越久,STW 时间就越长。
所以 CMS、G1 这类低停顿垃圾回收器希望做到:
标记阶段尽量和用户线程一起执行,减少 STW 时间。
但是用户线程一边运行,一边修改对象引用关系,就会导致 GC 标记时出现"不一致"。三色标记法就是为了解决并发标记过程中的对象状态管理问题。
2. 三种颜色分别是什么意思?
可以这样理解:
白色:还没被 GC 访问过
白色对象有两种可能:
- 真的不可达,是垃圾;
- 只是还没来得及被扫描到。
所以在标记刚开始时,所有对象默认都是白色。
灰色:对象本身被发现了,但它引用的对象还没扫描完
灰色对象代表:
这个对象已经确定是存活的,但它身上的引用还没有完全处理。
比如对象 A 引用了 B、C。
当 GC 找到 A 时,会先把 A 标成灰色,表示:
A 活着,但 A 里面引用的 B、C 还没看完。
灰色对象可以理解成"待处理队列"。
黑色:对象本身被发现了,并且它引用的对象也扫描完了
黑色对象代表:
这个对象确定存活,并且它直接引用的对象都已经被处理过了。
比如 GC 扫描完 A,发现 A 引用了 B、C,于是把 B、C 标成灰色,然后 A 自己变成黑色。
3. 正常三色标记过程
假设引用关系是:
text
GC Roots -> A -> B -> C
一开始:
text
A、B、C 都是白色
初始标记阶段:
text
GC Roots 直接找到 A
A 变成灰色
并发标记阶段:
text
扫描 A:
A 引用了 B
B 从白色变成灰色
A 扫描完成,变成黑色
继续扫描 B:
text
B 引用了 C
C 从白色变成灰色
B 扫描完成,变成黑色
继续扫描 C:
text
C 没有引用其他对象
C 扫描完成,变成黑色
最后没有灰色对象了,说明可达对象都扫描完了。
最终:
text
黑色对象:存活对象
白色对象:垃圾对象
4. 初始标记、并发标记、重新标记分别干什么?
初始标记:短暂 STW
初始标记只做一件事:
找出 GC Roots 直接关联的对象。
它不会扫描整个堆,所以速度比较快。
比如:
text
GC Roots -> A -> B -> C
初始标记只会先找到 A,把 A 标成灰色。
并发标记:和用户线程一起执行
并发标记就是从灰色对象开始,不断往下扫描引用链。
过程是:
text
取出一个灰色对象
扫描它引用的对象
把它引用的白色对象变成灰色
自己扫描完后变成黑色
不断重复,直到没有灰色对象。
这个阶段不需要完全 STW,所以用户线程还在运行。
重新标记:再次短暂 STW
并发标记时,用户线程可能修改对象引用关系。
比如:
text
A 原来引用 B
用户线程突然让 A 不引用 B 了
或者:
text
A 原来不引用 B
用户线程突然让 A 引用 B 了
这些变化可能导致 GC 标记不准确。
所以重新标记阶段需要 STW,处理并发标记期间发生过变化的引用关系。
5. 漏标问题是什么?
漏标是三色标记法中最危险的问题。
所谓漏标就是:
一个对象明明还活着,但是 GC 没有标记到它,最终把它当垃圾回收了。
这会导致程序出错,因为用户线程后面可能还要用这个对象。
6. 漏标发生的两个条件
你总结的两个条件是对的。
漏标需要同时满足两个条件:
条件一:黑色对象新增了对白色对象的引用
例如:
text
A 是黑色对象
C 是白色对象
用户线程执行:
java
A.c = C;
于是变成:
text
黑色 A -> 白色 C
问题是:A 已经被扫描完了,GC 后面不会再扫描 A。
所以 C 可能永远不会被发现。
条件二:灰色对象到白色对象的路径被切断
原本可能是:
text
灰色 B -> 白色 C
正常情况下,GC 后面扫描 B 时,会发现 C,然后把 C 标成灰色。
但是用户线程执行:
java
B.c = null;
变成:
text
灰色 B 白色 C
这样 C 就不能通过灰色对象 B 被发现了。
两个条件同时满足才会漏标
完整过程可以这样看:
原始引用关系:
text
GC Roots -> A -> B -> C
假设:
text
A 已经被扫描完,是黑色
B 还没扫描完,是灰色
C 还没被扫描,是白色
此时:
text
黑色 A -> 灰色 B -> 白色 C
用户线程做了两件事:
text
1. A 新增引用 C
2. B 删除引用 C
变成:
text
黑色 A -> C
黑色 A -> B
B 不再引用 C
此时 C 其实还是活着的,因为 A 引用了 C。
但是 GC 不会再扫描 A,因为 A 已经是黑色了。
B 也不再引用 C,所以扫描 B 时也找不到 C。
于是 C 一直是白色,最后被错误回收。
这就是漏标。
7. 为什么单独一个条件不会出问题?
只有条件一,不一定出问题
黑色对象 A 新增引用白色对象 C:
text
黑色 A -> 白色 C
但如果灰色对象 B 仍然引用 C:
text
灰色 B -> 白色 C
那么 GC 后面扫描 B 时,还是能找到 C。
所以不会漏标。
只有条件二,也不一定出问题
灰色对象 B 删除了对白色对象 C 的引用:
text
B 不再引用 C
但是如果没有黑色对象新增引用 C,那么说明 C 可能真的不可达了。
那 C 被回收是合理的。
所以也不会造成"错误回收"。
8. CMS 的增量更新怎么解决?
CMS 解决漏标的思路是:
关注"新增引用"。
也就是你说的条件一。
当用户线程在并发标记期间执行:
java
A.c = C;
也就是:
text
黑色对象 A 新增引用白色对象 C
CMS 会通过写屏障把这个新增引用记录下来。
重新标记阶段,再处理这些记录。
可以理解成:
既然黑色对象 A 已经扫描过了,但它现在又新增了一个引用 C,那我重新标记时再补查一下 C。
所以 CMS 的思路叫 增量更新。
"增量"指的是:
记录并发标记期间新增出来的引用关系。
9. G1 的原始快照怎么解决?
G1 解决漏标的思路是:
关注"删除引用"。
也就是你说的条件二。
原本:
text
灰色 B -> 白色 C
用户线程把这个引用删掉:
java
B.c = null;
G1 会通过写屏障记录下:
text
B 曾经引用过 C
重新标记阶段,G1 会根据这些记录继续处理 C。
它的核心思想是:
按照并发标记开始那一刻的对象引用关系来判断对象是否存活。
所以叫 原始快照 SATB,Snapshot-At-The-Beginning。
也就是说:
只要对象在标记开始时是活的,即使后来引用被删了,G1 这次 GC 也先认为它是活的。
这样可以避免漏标。
10. CMS 和 G1 的区别总结
| 回收器 | 解决思路 | 关注哪个条件 | 核心思想 |
|---|---|---|---|
| CMS | 增量更新 | 黑色对象新增对白色对象的引用 | 新增的引用要补标 |
| G1 | 原始快照 SATB | 灰色对象删除对白色对象的引用 | 删除前的引用也要保留 |
简单记法:
text
CMS:你新增了引用,我记下来。
G1:你删除了引用,我也记下来。
11. 多标问题是什么?
多标就是:
一个对象其实已经变成垃圾了,但这次 GC 仍然把它当成存活对象。
比如原来:
text
GC Roots -> A -> B
GC 已经扫描到了 B,把 B 标成黑色。
后来用户线程执行:
java
A.b = null;
此时 B 已经不可达了,理论上应该被回收。
但是 B 之前已经被标成黑色,GC 不会把它重新变回白色。
所以 B 这次不会被回收。
这就是多标。
12. 多标为什么可以接受?
因为多标只是让垃圾对象多活了一轮。
它不会造成程序错误。
这类对象也叫 浮动垃圾。
本次 GC 没回收掉,下一次 GC 时,如果它仍然不可达,就会被回收。
所以:
text
漏标:活对象被错杀,严重问题,必须解决。
多标:垃圾对象没回收,最多浪费一点内存,可以接受。
13. 最终可以这样记
三色标记法的核心逻辑:
text
白色:还没访问,最终可能是垃圾
灰色:已经访问,但引用还没扫描完
黑色:已经访问,引用也扫描完
并发标记的问题:
text
用户线程会修改引用关系,可能导致标记结果不准确
最严重的是漏标:
text
活对象没被标记,最后被错误回收
漏标发生条件:
text
1. 黑色对象新增对白色对象的引用
2. 灰色对象到白色对象的路径被切断
解决方案:
text
CMS:增量更新,记录新增引用
G1:原始快照,记录删除引用
多标问题:
text
垃圾对象被当成存活对象,变成浮动垃圾
下次 GC 再回收