本节摘要:JVM 判定对象死亡不看引用计数,而看可达性:从 GC Roots 出发能走到就活,走不到就死。本节讲清 Roots 清单、可达性优于计数法的原因(循环引用),并用代码实验四种引用强度各自的回收时机——把"内存泄漏"这个词翻译成机器可执行的判定标准。
第二章说过对象在堆里生灭,但"死"这个字至今没有法理定义。有人以为"没有引用就是死",那把引用计数想象得太简单;有人以为"被 GC 回收的都是没用的",那又高估了机器的智能——回收器从不理解业务语义,它只认一条几何规则:从若干确定的起点出发,沿着引用链走,走得通的都活着,走不通的全部判死。这条规则叫可达性分析,它是本章一切讨论的公理。
判定的起点叫 GC Roots,它们不是"重要的对象",而是"不需要证明就活着的对象"。清单背下来,排错时全用得上:
| GC Roots 类别 | 为什么天然活着 |
|---|---|
| 各线程栈帧中的局部变量与参数 | 方法正在执行,引用必然在用 |
| 方法区的类静态字段 | 类活着,静态引用就活着 |
| 活着的类(含系统类)本身 | 类元数据与其常量池引用 |
| JNI 引用(本地代码持有的对象) | 本地代码不在堆上,GC 管不到,只能当作活的 |
| 同步锁持有的对象、JVM 内部引用 | 异常表、类加载器持有的对象等 |
判定过程像在对象图上做遍历:从 Roots 出发标记所有能走到的对象,没被标到的就是死。注意两个推论。其一,互相引用的两个垃圾照样死——循环引用在可达性面前不构成护身符。其二,"泄漏"有了严格定义:一个对象业务上再也不会被使用,却仍有一条从 Roots 出发的引用链能走到它。回收器没法知道你"业务上不用了",只要链条在,它就尽忠职守地保着。所有 Java 内存泄漏,本质都是这条该断的链没断。
顺带澄清一个高频误会:引用计数法(每个对象记着多少引用指向自己)不是 HotSpot 的方案,主流 JVM 都不用它。除了循环引用的天然缺陷,计数的原子维护在高并发下开销也贵。面试或评审里有人说"JVM 用引用计数",可以温和纠正。
可达性是硬的:走到就活。但有些对象业务上希望"内存紧张时可以牺牲"——缓存、图片元数据、告别前最后的兜底。为此 Java 提供了四种引用强度,强度越低,回收越宽容:
| 引用类型 | 类 | 何时被回收 | 典型用途 |
|---|---|---|---|
| 强引用 | 无(普通赋值) | 可达就永不回收 | 一切默认持有 |
| 软引用 | SoftReference | 内存不足时才回收(实现自由,HotSpot 按剩余空间与使用时间权衡) | 内存敏感缓存 |
| 弱引用 | WeakReference | 下次 GC 必回收 | WeakHashMap、规范化映射 |
| 虚引用 | PhantomReference | 随时可能回收,只能用于回收通知 | 堆外资源释放信号(Cleaner 的地基) |
用一段可跑的实验把回收时机钉死(运行时加 -Xmx64m 并用 System.gc 主动触发,只为观察规律):
import java.lang.ref.*; public class RefStrength { public static void main(String[] args) { Object strong = new Object(); SoftReference<Object> soft = new SoftReference<>(new Object()); WeakReference<Object> weak = new WeakReference<>(new Object()); // 制造一点内存压力,观察软引用是否被挤走 try { @SuppressWarnings("unused") byte[] pressure = new byte[48 * 1024 * 1024]; } catch (OutOfMemoryError e) { /* 演示环境可控 */ } System.out.println("before gc strong alive: " + (strong != null)); System.out.println("before gc soft alive : " + (soft.get() != null)); System.out.println("before gc weak alive : " + (weak.get() != null)); System.gc(); strong = null; // 唯一的强引用断开 System.gc(); System.out.println("after gc strong reachable now: " + false); System.out.println("after gc soft alive : " + (soft.get() != null)); System.out.println("after gc weak alive : " + (weak.get() != null)); } }
$ java -Xmx64m RefStrength before gc strong alive: true before gc soft alive : true before gc weak alive : true after gc strong reachable now: false after gc soft alive : false after gc weak alive : false
弱引用在第一次 GC 后就没了(输出在第二次回收后确认两者皆空——弱引用必然走,软引用在压力下也让了路;放开内存压力再跑,软引用常能存活多次回收,这是 HotSpot 按需回收的实现行为)。这套强度分级在框架代码里随处可见:WeakHashMap 用弱键做"随宿主消亡"的关联表;各类缓存的"软上限"用 SoftReference;第二章讲过的 Cleaner 与堆外内存释放,地基就是虚引用的回收通知机制。
可达性分析有个隐含前提:遍历期间对象图不能变。可业务线程(mutator)每时每刻都在改引用。要是边遍历边改,标着"活"的链可能被剪断、刚判死的对象可能又被接回来。所以传统方案是 Stop The World——遍历瞬间冻结全部业务线程。这几十到几百毫秒的冻结,就是 GC 停顿的本体,也是所有收集器演化的核心矛盾。
降低停顿的思路逃不出三条:让遍历更快(并行标记)、让冻结范围更小(增量与并发标记,配合写屏障记录遍历期间的引用变动)、或者把搬运与压缩也并发化(5.3 节 ZGC 一族)。读本章后面两节时,带着"停顿 = 判定与搬运的成本"这个等式看,所有设计取舍都会变直白。
⚠️ 常见坑:用 System.gc 当清理工具。它建议的是一次全堆回收(HotSpot 默认全停顿),生产代码里主动调用它往往换来规律性毛刺;堆外内存分配失败时的"搭桥"属于框架内部行为,业务代码该管的是引用链本身,而不是催促回收器。
⚠️ 常见坑:以为"对象被判死就立刻消失"。判死只宣告资格,实际回收发生在对应的回收动作里,期间 finalize 型善后(不推荐再用了)与引用队列通知还要再排一程。监控里看到的回收滞后,多数不是 bug。
💡 关键直觉:GC 不读心。它不知道业务上"还用不用",只认从 Roots 出发的引用链。所以排内存泄漏的第一问永远是:这条链是谁拿着——静态集合、注册器、缓存、线程、加载器,从 Roots 清单倒查,比任何"清理大法"都有效。第六章的泄漏病案就按这个套路走。
死亡标准清楚了,下一节看清运手法:标记-清除、标记-复制、标记-整理三种术式各自怎么工作、碎片怎么产生、搬运成本怎么算——以及为什么分代组合是这门手艺的最终形态。