JVM 如何判断对象是否存活的?
结论先行:HotSpot 用**可达性分析(reachability analysis)**判断对象存活:从一组称为 GC Roots 的根对象出发,沿引用链遍历,能被遍历到的对象存活,遍历不到的对象可回收。引用计数(reference counting)虽然直观,但解决不了循环引用,还要为每次引用赋值付出原子开销,因此商用 JVM 不用它。
一、两种判定方式对比
| 维度 | 引用计数 | 可达性分析 |
|---|---|---|
| 基本思路 | 每个对象维护被引用次数,减到 0 即死 | 从 GC Roots 遍历,不可达即死 |
| 循环引用 | 无法识别,互相引用永不归零 | 天然解决,环上没有根就整体回收 |
| 额外开销 | 每次引用赋值都要增减,需保证原子性 | 只在 GC 时遍历,平时零开销 |
| 实现位置 | 编译器插桩或对象头字段 | 收集器的标记阶段 |
| 典型代表 | Python、Swift、早期 Objective-C | HotSpot 全系收集器 |
GC Roots
├── 线程栈局部变量 A ──→ B ──→ C (A、B、C 存活)
├── 静态字段 D ──→ E (D、E 存活)
└── 常量 / JNI 引用 / 同步锁 ...
孤立对象环:F ──→ G ──→ F
没有任何根指向 F,整环一起回收;引用计数法会认为两者计数都为 1
二、谁能当 GC Roots
| 根类型 | 说明 |
|---|---|
| 虚拟机栈中局部变量表引用的对象 | 方法参数、局部变量、临时值 |
| 方法区中静态属性引用的对象 | static 字段 |
| 方法区中常量引用的对象 | 字符串常量池、static final |
| 本地方法栈 JNI 引用的对象 | native 方法持有的全局引用 |
| 被 synchronized 持有的对象 | 监视器(monitor)关联的对象 |
| JVM 内部引用 | 系统类加载器、基本类型包装类缓存、常驻异常对象 |
三、一次完整的判定不是「一次标记」
对象被判定不可达后并不立即消失,而是走「两次标记」:
- 第一次标记:可达性分析后发现不可达,检查是否需要执行
finalize()——未覆盖或已执行过则直接回收。 - 若需要执行,对象被放进
F=Queue,由低优先级线程触发finalize()。 - 第二次标记:
finalize()中若对象重新与引用链上的对象建立关联,则移出队列逃过一劫。
public class FinalizeEscape {
private static FinalizeEscape SAVE_HOOK;
@Override
protected void finalize() throws Throwable {
super.finalize();
SAVE_HOOK = this; // 把自己挂回静态字段,完成自救
System.out.println("finalize 执行,对象自救成功");
}
public static void main(String[] args) throws Exception {
SAVE_HOOK = new FinalizeEscape();
SAVE_HOOK = null; // 断开根引用
System.gc();
Thread.sleep(500); // 等 finalize 线程跑完
System.out.println("第一次: " + (SAVE_HOOK != null ? "存活" : "已死"));
SAVE_HOOK = null; // 再断一次
System.gc();
Thread.sleep(500);
System.out.println("第二次: " + (SAVE_HOOK != null ? "存活" : "已死"));
}
}
// 输出:
// finalize 执行,对象自救成功
// 第一次: 存活
// 第二次: 已死(finalize 只执行一次)
finalize() 在 JDK 9 起被标记为 deprecated,JDK 18 起默认禁用;替代方案是 Cleaner 或 try-with-resources。
四、三色标记:并发场景下的可达性分析
并发收集器在应用线程运行时标记,用三种颜色描述状态:
| 颜色 | 含义 |
|---|---|
| 白色 | 尚未访问,最终仍是白色即回收 |
| 灰色 | 自身已访问,成员未扫完 |
| 黑色 | 自身与成员都扫完 |
并发标记的漏标只会在两个条件同时成立时发生:赋值器删掉了灰色对象到白色对象的引用(多标),又让黑色对象新增了指向该白色对象的引用(少标)。解决办法两条路:**增量更新(incremental update)**记录黑色对象新增的引用,重新标记时再扫一遍;**原始快照(SATB)**记录删除的旧引用,按快照继续标记。
# 观察三色标记的并发细节(需 debug 版 JVM)
java -Xlog:gc+marking=debug -XX:+UseG1GC -jar app.jar
五、常见追问
| 追问 | 回答要点 |
|---|---|
| 引用计数为什么被淘汰 | 循环引用、无法处理、每次赋值都要原子更新,吞吐还不如遍历一次 |
| 可达性分析会 STW 吗 | 根节点枚举必须 STW,其余标记阶段可并发;OopMap + 安全点让根枚举足够快 |
| 不可达就一定被回收吗 | 不一定,软引用、弱引用、finalize() 自救、Cleaner 都会影响最终结果 |
| 为什么还要三色标记 | 并发标记必须能表达「扫描到一半」的中间状态,否则无法增量推进 |
| 栈上局部变量出作用域后立刻回收吗 | 不保证,只要局部变量表槽位仍持有引用,对象就还是 GC Root 可达 |
小结
判断存活靠「从 GC Roots 出发的可达性分析」,引用计数只作为对照概念出现。真正的工程难点在并发标记的正确性,三色标记加读/写屏障才是现代收集器的核心。