Skip to content

对象存活判断:引用计数 vs 可达性分析

问题

JVM 如何判断一个对象是否"已死"?引用计数法和可达性分析的原理分别是什么,为什么主流 JVM 选用后者?

核心差异:两种世界观

判定对象是否存活,是垃圾回收的第一步。选错了算法,要么漏回收(内存泄漏),要么误回收(悬空指针,Java 没有这个问题,但 C++ 有)。业界有两种思路。

1. 引用计数法(Reference Counting)

每个对象维护一个引用计数器,被引用时 +1,引用失效时 -1,计数器为 0 即判为可回收。

优点很明显:简单、高效,回收可实时执行——不需要 STW 暂停,对象引用为 0 的瞬间就能回收,非常适合实时性敏感的场景(比如游戏引擎)。

但有一个致命缺陷:无法处理循环引用

java
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 的根对象出发,向下遍历引用链,无法被遍历到的对象即为不可达(可回收)。

java
// 对象图:
// 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 不仅要判断存活,还要做分代、整理、压缩。引用计数法在这种场景下反而更复杂:

  1. 分代意味着对象移动——复制和整理阶段,对象地址变了,引用计数需要逐一更新,成本极高。Young GC 一次可能移动几百万个对象,每个都要更新计数。
  2. 并发标记时需要快照——引用计数法在并发场景下对计数器增减的原子性要求极高,多线程同时修改计数器,CAS 竞争激烈,性能开销大。
  3. 元数据膨胀——每个对象需要额外的计数空间,压缩指针和对象头已经够紧凑了(64 位 JVM 默认开启指针压缩时对象头 12 字节),再加 4 字节计数字段就是 33% 的头开销。256 堆内存,光计数就占 8+ GB。
  4. 引用计数法的"残余 STW"——很多人以为引用计数法不需要 STW,但 Python 的 gc 模块在循环引用清除时也要 STW。引用计数只是把"检测"分摊到运行时,但"清理"阶段仍然需要暂停。

P7/P8 延伸:三色标记与并发问题

可达性分析本身很简单,难点在于并发标记——GC 线程和业务线程同时运行,对象图在变化,怎么保证不误判?

HotSpot 采用 三色标记算法(Tri-Color Marking):

  • 白色:未访问(不可达,可回收)
  • 灰色:自身已访问,但引用的子对象未访问完
  • 黑色:自身和所有子对象都已访问
java
// 初始状态:所有对象白色
// 从 Root 开始,标记为灰色
// 灰色入队,处理子对象

// 正确流程:
// 1. Root(灰) -> 处理子对象 -> Root(黑), A(灰)
// 2. A(灰) -> 处理子对象 -> A(黑), B(灰), C(灰)
// 3. B(灰) -> 处理子对象 -> B(黑)
// 4. C(灰) -> 处理子对象 -> C(黑)
// 结束:白色对象即为可回收

对象消失问题(漏标问题)

并发标记阶段最大的问题是**"对象消失"**(漏标)——一个黑色对象(已扫描完)新增了一个指向白色对象的引用,但黑色对象不会再被扫描,导致白色对象被误判为不可达从而被回收。这是面试高频考点。

对象消失的充分必要条件(Wilson 1992 年证明):

  1. 黑色对象插入了一个指向白色对象的新引用
  2. 灰色对象删除了指向该白色对象的唯一引用

必须同时满足这两个条件,才会发生对象消失。只要破坏其中任意一个,就能解决。

java
// 并发标记场景:
// 初始状态:A(黑) -> (无子对象), B(灰) -> C(白)
// 业务线程执行:
//   A.ref = C;   // 条件1:黑色对象新增指向白色对象的引用
//   B.ref = null; // 条件2:灰色对象删除指向白色对象的唯一引用
// 结果:C(白) 本应存活,但被错误标记为可回收 → 致命错误!

两种主流解决方案

CMS 用增量更新(Incremental Update):破坏条件1。黑色对象引用新白色对象时,将黑色对象重新标记为灰色,等待重新扫描。CMS 的做法是在 Write Barrier 中记录"发生了新引用的黑色对象",然后在 Remark 阶段统一重新扫描。

java
// 伪代码: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 结束时,按快照标记——多标了没关系(浮动垃圾),但不会误回收存活对象

java
// 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 的对象移动和指针压缩仍然不兼容

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。