对象存活判断:引用计数 vs 可达性分析
问题
JVM 如何判断一个对象是否"已死"?引用计数法和可达性分析的原理分别是什么,为什么主流 JVM 选用后者?
核心差异:两种世界观
判定对象是否存活,是垃圾回收的第一步。选错了算法,要么漏回收(内存泄漏),要么误回收(悬空指针,Java 没有这个问题,但 C++ 有)。业界有两种思路。
1. 引用计数法(Reference Counting)
每个对象维护一个引用计数器,被引用时 +1,引用失效时 -1,计数器为 0 即判为可回收。
优点很明显:简单、高效,回收可实时执行——不需要 STW 暂停,对象引用为 0 的瞬间就能回收,非常适合实时性敏感的场景(比如游戏引擎)。
但有一个致命缺陷:无法处理循环引用。
class Node {
Node next;
}
Node a = new Node(); // refCount(a) = 1
Node b = new Node(); // refCount(b) = 1
a.next = b; // refCount(b) = 2
b.next = a; // refCount(a) = 2
a = null; // refCount(a) = 1 ← 减完还是 1,因为 b.next 仍指向 a
b = null; // refCount(b) = 1 ← 同理
// 两者外部再无引用,但计数器永远不为 0,变成死内存Python 和 Objective-C 使用引用计数,但都加了辅助机制——Python 有 gc 模块专门处理循环引用(分代 GC 扫一遍,标记-清除循环引用的垃圾),ObjC 有 ARC + 弱引用表。代价是额外的 GC 扫描开销,以及弱引用表的维护成本。
真实踩坑场景:曾有个 Python 实时数据处理服务,用引用计数做内存管理,上线一周后 OOM 了。原因是一个 callback 闭包引用了父对象,父对象又引用了 callback,形成循环。Python 的 gc 模块默认只跑 threshold 触发,流量低的时候一直不触发,堆积到 OOM。最后手动调了 gc.set_threshold(700, 10, 5) 加速循环引用回收,才稳住。换可达性分析的语言(Java/Go)就不会有这种问题。
2. 可达性分析(Reachability Analysis)
从一组称为 GC Roots 的根对象出发,向下遍历引用链,无法被遍历到的对象即为不可达(可回收)。
// 对象图:
// Root -> A -> B -> C
// \
// -> D
// E -> F(E 不可达,因为 Root 没有指向 E)
//
// 可达性分析从 Root 出发,标记 A、B、C、D 为存活
// E、F 不可达,标记为可回收。即使 E 和 F 互相引用,也不影响判断。循环引用不会成为问题:因为分析从 Root 出发,A 和 B 互相引用但外部不可达,两者都不会被标记为存活。
GC Roots 具体包括哪些? 面试常考,得记住:
- 虚拟机栈引用的对象(局部变量表、操作数栈)
- 方法区中静态属性引用的对象(static 字段)
- 方法区中常量引用的对象(final 常量池)
- 本地方法栈中 JNI 引用的对象(global refs)
- 活跃线程(Thread 对象本身)
- 被同步锁(synchronized)持有的对象
- JVM 内部引用(类加载器、基本类型的 Class 对象、常驻异常对象等)
为什么 HotSpot 选择了可达性分析
原因不只是一个"循环引用"这么简单。Java 的 GC 不仅要判断存活,还要做分代、整理、压缩。引用计数法在这种场景下反而更复杂:
- 分代意味着对象移动——复制和整理阶段,对象地址变了,引用计数需要逐一更新,成本极高。Young GC 一次可能移动几百万个对象,每个都要更新计数。
- 并发标记时需要快照——引用计数法在并发场景下对计数器增减的原子性要求极高,多线程同时修改计数器,CAS 竞争激烈,性能开销大。
- 元数据膨胀——每个对象需要额外的计数空间,压缩指针和对象头已经够紧凑了(64 位 JVM 默认开启指针压缩时对象头 12 字节),再加 4 字节计数字段就是 33% 的头开销。256 堆内存,光计数就占 8+ GB。
- 引用计数法的"残余 STW"——很多人以为引用计数法不需要 STW,但 Python 的 gc 模块在循环引用清除时也要 STW。引用计数只是把"检测"分摊到运行时,但"清理"阶段仍然需要暂停。
P7/P8 延伸:三色标记与并发问题
可达性分析本身很简单,难点在于并发标记——GC 线程和业务线程同时运行,对象图在变化,怎么保证不误判?
HotSpot 采用 三色标记算法(Tri-Color Marking):
- 白色:未访问(不可达,可回收)
- 灰色:自身已访问,但引用的子对象未访问完
- 黑色:自身和所有子对象都已访问
// 初始状态:所有对象白色
// 从 Root 开始,标记为灰色
// 灰色入队,处理子对象
// 正确流程:
// 1. Root(灰) -> 处理子对象 -> Root(黑), A(灰)
// 2. A(灰) -> 处理子对象 -> A(黑), B(灰), C(灰)
// 3. B(灰) -> 处理子对象 -> B(黑)
// 4. C(灰) -> 处理子对象 -> C(黑)
// 结束:白色对象即为可回收对象消失问题(漏标问题)
并发标记阶段最大的问题是**"对象消失"**(漏标)——一个黑色对象(已扫描完)新增了一个指向白色对象的引用,但黑色对象不会再被扫描,导致白色对象被误判为不可达从而被回收。这是面试高频考点。
对象消失的充分必要条件(Wilson 1992 年证明):
- 黑色对象插入了一个指向白色对象的新引用
- 灰色对象删除了指向该白色对象的唯一引用
必须同时满足这两个条件,才会发生对象消失。只要破坏其中任意一个,就能解决。
// 并发标记场景:
// 初始状态:A(黑) -> (无子对象), B(灰) -> C(白)
// 业务线程执行:
// A.ref = C; // 条件1:黑色对象新增指向白色对象的引用
// B.ref = null; // 条件2:灰色对象删除指向白色对象的唯一引用
// 结果:C(白) 本应存活,但被错误标记为可回收 → 致命错误!两种主流解决方案
CMS 用增量更新(Incremental Update):破坏条件1。黑色对象引用新白色对象时,将黑色对象重新标记为灰色,等待重新扫描。CMS 的做法是在 Write Barrier 中记录"发生了新引用的黑色对象",然后在 Remark 阶段统一重新扫描。
// 伪代码:CMS 的写屏障
void write_barrier(Object* black, Object** field, Object* white) {
if (black.is_marked_black()) {
// 记录黑色对象,准备重新扫描
cms_remark_set.add(black);
}
*field = white;
}代价:Remark 阶段需要 STW,且需要扫描所有记录的黑色对象。如果并发期间写操作频繁,Remark 阶段会很长。线上遇到过 CMS Remark 阶段 STW 超过 2 秒的案例,排查下来是因为业务代码在 for 循环中反复修改某个集合的引用,每次触发增量更新记录。
G1 用 SATB(Snapshot-At-The-Beginning):破坏条件2。记录 GC 开始时的对象图快照,并发标记期间引用发生变化时,记录被移除的引用(而非新增的引用)。GC 结束时,按快照标记——多标了没关系(浮动垃圾),但不会误回收存活对象。
// G1 的 SATB 写屏障
void satb_write_barrier(Object* gray, Object** field, Object* new_ref) {
Object* old_ref = *field;
if (old_ref != NULL && gray.is_marked()) {
// 记录被移除的引用,保证快照完整性
satb_queue.add(old_ref);
}
*field = new_ref;
}代价:SATB 会保留 GC 开始那一瞬间的对象图快照,如果并发期间大量对象被断开引用,这些对象会成为浮动垃圾(floating garbage),需要等到下一次 GC 才能回收。浮动垃圾率通常比 CMS 高 5%-15%,但好处是不需要 STW 的 Remark 阶段,G1 的最终标记阶段只需要处理 SATB 队列,非常快。
面试实战对比
| 维度 | CMS 增量更新 | G1 SATB |
|---|---|---|
| 破坏条件 | 条件1(新增引用) | 条件2(删除引用) |
| 写屏障开销 | 记录黑色对象引用变更 | 记录被删除的引用 |
| Remark 阶段 | STW,扫描所有改动过的黑色对象 | 只需要处理 SATB 队列,STW 极短 |
| 浮动垃圾率 | 低(比 SATB 准确) | 高 5%-15% |
| 典型 Remark 耗时 | 100ms-2s(取决于写操作频率) | < 10ms |
| 适合场景 | 低延迟不敏感,吞吐优先 | 低延迟敏感,需要可控 STW |
真实踩坑案例:某金融交易系统,使用 CMS 收集器,每天下午 3 点高峰期 Remark 阶段 STW 持续 1.5-2 秒,导致交易超时。排查发现是某个定时任务在 3 点批量更新缓存引用,触发了大量增量更新记录。解决方式:切到 G1,SATB 没有长 Remark 问题。但代价是浮动垃圾从 2% 升到 12%,需要调大堆内存(从 4G 调到 6G)来容纳浮动垃圾。
总结
| 维度 | 引用计数 | 可达性分析 |
|---|---|---|
| 循环引用 | ❌ 无法处理 | ✅ 天然免疫 |
| 实时性 | ✅ 实时回收 | ⏳ 需要 STW 标记 |
| 对象移动 | ❌ 计数更新成本高 | ✅ 只需更新引用 |
| 并发兼容 | ❌ CAS 竞争大 | ✅ 三色标记 + SATB/增量更新 |
| 元数据开销 | ❌ 每个对象多 4 字节 | ✅ 只需对象头标记位 |
| 主流 JVM 采用 | ❌ | ✅ |
一句话结论:引用计数法在简单场景下高效,但循环引用、对象移动、并发标记三个硬伤让它不适合 Java 这种需要分代 + 整理 + 并发 GC 的 VM。可达性分析 + 三色标记是工业级 GC 的基石,CMS 和 G1 在这之上的差异(增量更新 vs SATB),直接决定了各自的 STW 特性和浮动垃圾率。
面试追问方向:
- 为什么 R 大说"引用计数法其实也可以做分代"?——通过"写时复制"计数,但成本比标记-复制高得多,业界没有工业实现
- Python 的引用计数 + 分代 GC 和 Java 的纯可达性分析,哪个更优?——没有绝对,Python 偏好实时释放内存(适合脚本场景),Java 偏好吞吐和可控 STW
- 如果有一天出现了硬件支持的引用计数(比如 CPU 指令级),会改变格局吗?——会的,Apple 的 M 系列芯片对引用计数有硬件加速,ObjC 场景下受益明显,但 Java 的对象移动和指针压缩仍然不兼容