5.1 可达性分析与四种引用:死亡判定标准


5.1 可达性分析与四种引用:死亡判定标准

本节摘要:JVM 判定对象死亡不看引用计数,而看可达性:从 GC Roots 出发能走到就活,走不到就死。本节讲清 Roots 清单、可达性优于计数法的原因(循环引用),并用代码实验四种引用强度各自的回收时机——把"内存泄漏"这个词翻译成机器可执行的判定标准。

第二章说过对象在堆里生灭,但"死"这个字至今没有法理定义。有人以为"没有引用就是死",那把引用计数想象得太简单;有人以为"被 GC 回收的都是没用的",那又高估了机器的智能——回收器从不理解业务语义,它只认一条几何规则:从若干确定的起点出发,沿着引用链走,走得通的都活着,走不通的全部判死。这条规则叫可达性分析,它是本章一切讨论的公理。

从 Roots 出发的生死判定

判定的起点叫 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 清单倒查,比任何"清理大法"都有效。第六章的泄漏病案就按这个套路走。

要点回顾

  • 可达性是唯一判死标准:从 GC Roots 出发走不通即死,循环引用不豁免;
  • Roots 清单五类:线程栈引用、静态字段、活着的类、JNI 引用、锁与内部引用;
  • 泄漏的严格定义:业务不再使用但 Roots 链仍在——链没断,不是机器不勤快;
  • 四种引用强度:强不弃、软内存紧张才弃、弱 GC 必弃、虚只给回收通知;
  • 判定需要一致的图:传统方案以 Stop The World 换取遍历正确性,停顿成本由此而来,收集器演化全在压这个成本。

死亡标准清楚了,下一节看清运手法:标记-清除、标记-复制、标记-整理三种术式各自怎么工作、碎片怎么产生、搬运成本怎么算——以及为什么分代组合是这门手艺的最终形态。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U