04 垃圾回收基础——可达性分析、安全点与安全区域
摘要:
GC 要回收内存,首先必须解决一个核心问题:哪些对象是”垃圾”,哪些是”存活”的? 这个看似简单的问题,背后隐藏着深刻的工程挑战。本文从两种判断对象存活的算法出发——引用计数法(简单直觉,但无法处理循环引用,被 Java 放弃)与可达性分析法(从 GC Roots 出发遍历引用图,是 HotSpot 的选择),深入剖析 GC Roots 的精确范围(哪些对象是根),以及 Java 四种引用类型(强/软/弱/虚引用)与 GC 行为的关系。然后进入更难的工程问题:可达性分析必须在一个一致性快照下进行(否则分析过程中对象关系的变化会导致存活对象被误回收),这要求所有 Java 线程暂停(Stop-The-World)——而让所有线程在合适的位置安全暂停,正是安全点(Safepoint) 与安全区域(Safe Region) 机制要解决的问题。
第 1 章 垃圾的定义:从直觉到工程严格化
1.1 什么是”垃圾”
在 Java 中,垃圾(Garbage) 的定义是:不再被任何活跃代码所引用、因此不可能再被程序访问的对象。换句话说,是那些”已经没有人需要、但仍然占着内存”的对象。
这个定义听起来很直观,但工程实现时有一个本质挑战:JVM 如何在程序运行过程中,高效且准确地判断哪些对象符合这个定义?
两种思路:
- 引用计数(Reference Counting):为每个对象维护一个”被引用次数”计数器,计数降为 0 时即为垃圾。
- 可达性分析(Reachability Analysis):从一组”根对象”出发,遍历整个对象引用图,无法被到达的对象即为垃圾。
1.2 引用计数法:直觉正确,但有致命缺陷
工作原理:每个对象维护一个整型引用计数器 refCount。当有变量引用该对象时,refCount++;当引用失效时,refCount--。当 refCount == 0 时,对象被立即回收。
Object A → Object B(A 持有对 B 的引用)
B.refCount = 1
Object A 的引用消失 → B.refCount = 0 → B 立即被回收
优势:实现简单,对象一旦不再被引用就立即回收(无需等待 GC),内存释放及时,不会有长时间的 GC 停顿。CPython(Python 解释器)就使用引用计数。
致命缺陷——循环引用(Circular Reference):
// 循环引用:A 引用 B,B 引用 A
class Node {
Node next;
}
Node a = new Node();
Node b = new Node();
a.next = b; // a → b,b.refCount = 1
b.next = a; // b → a,a.refCount = 1
a = null; // a 的外部引用消失,但 a 对象的 refCount 仍 = 1(因为 b.next 还引用它)
b = null; // b 的外部引用消失,但 b 对象的 refCount 仍 = 1(因为 a.next 还引用它)
// 此时:
// a 对象:外部引用 = 0,但 refCount = 1(被 b 引用)→ 不会被回收,内存泄漏!
// b 对象:外部引用 = 0,但 refCount = 1(被 a 引用)→ 不会被回收,内存泄漏!在 Java 的对象图中,循环引用极为普遍(双向链表、父子节点互相引用、事件监听器等)。如果使用引用计数,这些循环结构将永远无法被回收,是严重的内存泄漏。
Java 选择放弃引用计数,转而使用可达性分析——后者从根本上解决了循环引用问题(循环引用的对象如果没有外部 GC Root 能到达,就一定会被判为垃圾并回收)。
第 2 章 可达性分析——从根出发遍历引用图
2.1 基本思想
可达性分析(Reachability Analysis) 的核心思想是:从一组被称为 GC Roots 的根对象出发,沿着引用关系(a.field → b)向下遍历,能被遍历到(“可达”)的对象都是存活的,遍历结束后没有被到达的对象就是垃圾,可以被回收。
GC Roots(根集合)
↓ 引用
Object A (可达,存活)
↓ 引用
Object B (可达,存活)
↓ 引用
Object C (可达,存活)
Object D ←── 只被 Object E 引用
Object E ←── 只被 Object D 引用(循环引用,但均不可达)
→ D 和 E 都不可达,即为垃圾,可以回收
循环引用 D ↔ E 在可达性分析中完全不是问题——只要 D 和 E 都不在从 GC Roots 出发的可达路径上,它们就是垃圾,无论它们互相引用多少次。
2.2 GC Roots 是什么
GC Roots(垃圾回收根) 是可达性分析的起点集合,代表”肯定不是垃圾的对象”。在 HotSpot VM 中,GC Roots 包括以下几类:
虚拟机栈中的引用:每个线程的虚拟机栈中,每个栈帧的局部变量表里保存着对象引用(如方法参数、局部变量)。这些引用指向的对象,正在被活跃的方法使用,当然不能回收。
public void process() {
User user = new User(); // user 是局部变量,在虚拟机栈中,是 GC Root 的候选
doSomething(user); // user 对象在 process() 方法执行期间是可达的
}
// process() 返回后,user 的栈帧被销毁,user 引用消失,User 对象可能成为垃圾本地方法栈中的引用:正在执行 JNI 本地方法时,本地代码持有的对象引用(通过 NewGlobalRef/NewLocalRef 创建的 JNI 引用)。
方法区中的静态变量引用:类的静态字段(static 字段)指向的对象。静态变量伴随类的整个生命周期存在,是长生命周期的 GC Root。
public class Cache {
// static 字段是 GC Root,cacheMap 引用的对象不会被 GC 回收
private static Map<String, Object> cacheMap = new HashMap<>();
}方法区中的常量引用:运行时常量池中被 final 修饰的字符串常量、类常量引用的对象。
被同步锁持有的对象:正在被 synchronized 持有的对象(锁对象),因为活跃的 synchronized 块意味着有线程正在使用它。
JVM 内部的引用:JVM 自身使用的一些对象,包括:
- 基本数据类型的 Class 对象(
Integer.TYPE、int.class等) - 常驻的系统类(
java.lang.String、java.lang.Object等) - 异常对象(
NullPointerException等系统常用异常的实例,提前创建好供重用) - 类加载器(
ClassLoader对象本身就是 GC Root,这也是类加载泄漏的根源)
核心概念:GC Roots 的边界
记忆 GC Roots 的方法:凡是”还活着的线程正在使用的”、“类级别生命周期的”、“JVM 内部必须保留的”,就是 GC Root。GC Roots 代表程序仍然”需要”的那些对象,是所有存活对象的入口。
2.3 可达性分析的三色标记模型
可达性分析在并发 GC 中通常用三色标记(Tri-color Marking) 来描述:
- 白色(White):尚未被 GC 扫描到的对象。分析开始时所有对象都是白色。分析结束后仍是白色的对象,就是垃圾。
- 灰色(Gray):已被 GC 发现(本身已标记为存活),但其引用的对象还未全部扫描完。灰色对象是扫描的”工作队列”。
- 黑色(Black):本身已标记为存活,且其所有引用的对象也都已扫描完毕。黑色对象是”已完全处理”的存活对象。
分析过程:
- 将所有 GC Roots 标记为灰色,放入工作队列
- 取出一个灰色对象,扫描它的所有引用字段:将白色的引用对象标记为灰色;将当前对象标记为黑色
- 重复步骤 2,直到工作队列为空(没有灰色对象)
- 分析结束:黑色 = 存活,白色 = 垃圾
三色标记为并发 GC 中的增量标记和并发标记提供了理论框架——并发标记时,GC 线程和用户线程同时运行,用户线程可能修改引用关系,可能破坏三色标记的不变式,需要写屏障(Write Barrier)来维护一致性(这是 CMS、G1、ZGC 的核心设计问题之一,后续章节详细展开)。
第 3 章 四种引用类型——GC 行为的精细控制
3.1 为什么需要不止一种引用
在 JDK 1.2 之前,Java 只有一种引用:强引用(Strong Reference)——只要引用存在,对象就不会被 GC 回收。这在大多数场景下是正确的,但有时我们需要更微妙的控制。
典型场景:内存敏感的缓存。你希望缓存中的对象在内存充足时保留,但当内存紧张时,GC 可以将其回收(宁可缓存失效,也不要 OOM)。用强引用实现这种缓存是做不到的——你无法告诉 GC “在内存不足时回收这些对象”。
JDK 1.2 引入了四种引用类型,赋予程序员对 GC 行为更精细的控制:
3.2 强引用(Strong Reference)
就是普通的 new 赋值,不需要任何特殊语法:
Object obj = new Object(); // 强引用GC 行为:只要强引用存在,对象永远不会被 GC 回收,即使发生 OOM 也不例外(OOM 时 JVM 会抛出错误而不是回收强引用对象)。
“消除”强引用:将引用变量赋值为 null(obj = null),使对象不再可达,GC 才有机会回收它。
3.3 软引用(Soft Reference)
描述一些有用但非必需的对象:
SoftReference<byte[]> softRef = new SoftReference<>(new byte[1024 * 1024]);
byte[] data = softRef.get(); // 可能返回 null(如果对象已被 GC 回收)GC 行为:当 JVM 内存充足时,软引用的对象不会被回收;当 JVM 面临 OOM 威胁时(内存即将耗尽),软引用的对象会被 GC 回收(在抛出 OOM 之前,JVM 会先尝试回收所有软引用)。
典型用途:内存敏感的缓存(图片缓存、页面缓存)。当内存不足时,GC 自动清理缓存,优雅降级。
JVM 的 SoftReference 回收策略:HotSpot 对软引用的回收策略是:软引用对象自上次被访问后,若经过 heap_free_percent * clock_interval(与堆空余空间和时间有关)还未被访问,则在下次 Full GC 时被回收。参数 -XX:SoftRefLRUPolicyMSPerMB=1000(默认 1000ms/MB)控制每 MB 空闲堆空间对应的软引用存活时间。
3.4 弱引用(Weak Reference)
描述非必需的对象,生命周期比软引用更短:
WeakReference<Object> weakRef = new WeakReference<>(new Object());
Object obj = weakRef.get(); // 可能返回 nullGC 行为:无论内存是否充足,只要 GC 运行,弱引用的对象就会被回收(前提是没有强引用指向该对象)。
典型用途:
WeakHashMap:key 使用弱引用,key 对象不再被其他强引用持有时,整个 Map 条目自动被删除,避免内存泄漏- 15 ThreadLocal 的实现原理与内存泄漏——线程封闭的正确姿势 中
ThreadLocalMap.Entry.key就是弱引用——ThreadLocal对象的外部强引用消失后,Entry 的 key 变为 null,成为可清理的”stale entry”
3.5 虚引用(Phantom Reference)
最弱的引用,几乎感知不到对象的存在:
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), queue);
phantomRef.get(); // 永远返回 null!虚引用无法访问对象GC 行为:虚引用完全不影响 GC 的回收决定(就好像这个引用不存在)。但当对象被 GC 回收时,JVM 会将对应的虚引用放入关联的 ReferenceQueue,程序可以通过监听 ReferenceQueue 来感知对象被回收的事件。
典型用途:
- 对象销毁时的资源清理(替代
finalize()的更安全方案) DirectByteBuffer的堆外内存清理(sun.misc.Cleaner继承自PhantomReference,当DirectByteBuffer对象被 GC 时,Cleaner 的回调会被触发,释放对应的堆外内存)
3.6 四种引用的对比
| 引用类型 | 类 | GC 回收时机 | get() 返回 | 典型用途 |
|---|---|---|---|---|
| 强引用 | 直接赋值 | 永不(只要引用存在) | 对象本身 | 绝大多数场景 |
| 软引用 | SoftReference | OOM 前 | 对象或 null | 内存敏感缓存 |
| 弱引用 | WeakReference | 任意 GC | 对象或 null | WeakHashMap、ThreadLocalMap.Entry.key |
| 虚引用 | PhantomReference | 任意 GC(不影响) | 永远 null | 资源清理回调、堆外内存释放 |
第 4 章 Stop-The-World——GC 的世界暂停
4.1 为什么需要 STW
可达性分析必须在一个一致性快照(Consistent Snapshot) 上进行——也就是说,分析过程中,对象之间的引用关系不能发生变化。
试想:GC 正在标记对象图,已经扫描完了对象 A(标记为黑色),发现 A 不引用对象 X。此时用户线程修改了 A 的引用字段,让 A 指向了 X,同时删除了唯一指向 X 的其他引用。结果:X 通过 A 是可达的(A 是黑色,不会再被重新扫描),但 GC 认为 X 是白色(垃圾)而将其回收——存活对象被误回收,程序崩溃。
这个问题称为**“对象丢失(Object Lost)”** 或**“漏标”**。
最彻底的解决方案是:在分析期间暂停所有 Java 线程(Stop-The-World,STW),使引用关系在分析期间保持冻结。这确保了可达性分析的正确性。
STW 的代价是显而易见的:应用程序在 GC 期间无响应,对外表现为延迟(Latency)抖动——用户可能感受到请求突然变慢或无响应。GC 停顿时间是并发 GC 技术(CMS、G1、ZGC)重点优化的核心指标。
4.2 精确式 GC 与保守式 GC
STW 时,GC 需要找到所有 GC Roots(特别是虚拟机栈中的对象引用)。有两种策略:
保守式 GC(Conservative GC):将栈上所有看起来像指针的值(大小、对齐满足指针条件)都当作对象引用处理。不需要额外的类型信息,但可能把整数误认为指针,导致本应回收的对象被留下,也无法移动对象(因为无法确定某个值是真正的指针还是只是恰好看起来像)。早期 C 语言的保守式 GC(如 Boehm GC)使用此方式。
精确式 GC(Exact/Precise GC):JVM 在编译期就知道每个栈帧中哪些位置存储的是对象引用(reference 类型)、哪些是基本数据类型(int、long 等),运行时通过 OopMap 精确记录这些信息,GC 无需猜测,直接读取 OopMap 找到所有引用。
HotSpot 使用精确式 GC + OopMap,这是 STW 后快速枚举 GC Roots 的基础。
第 5 章 OopMap——精确 GC 的数据结构
5.1 什么是 OopMap
OopMap(Ordinary Object Pointer Map) 是 HotSpot 在类加载和 JIT 编译时生成的数据结构,它记录了特定代码位置上,栈帧中哪些位置(哪些 Slot)存储着对象引用(oop)。
“Oop” 是 HotSpot 内部对 Java 对象引用(指针)的称呼(Ordinary Object Pointer)。OopMap 就是”引用所在位置的地图”。
方法 foo() 的栈帧在偏移量 0x0042 处的 OopMap:
Slot 0: reference(this 引用)
Slot 1: int(非引用,跳过)
Slot 2: reference(局部变量 user)
Slot 3: long(非引用,跳过)
...
GC 在 STW 后,遍历所有线程的虚拟机栈,对每个栈帧查询对应的 OopMap,就能准确找到该帧中所有的对象引用,加入 GC Roots 集合,进行可达性分析。
5.2 OopMap 的维护
解释执行时:解释器在每次方法调用、循环回跳等特定位置都会更新 OopMap。
JIT 编译时:C1/C2 编译器在生成机器码时,会在每个安全点(见下节)处记录 OopMap——此时哪些寄存器和栈位置存储着对象引用。
OopMap 的存在使 HotSpot 无需扫描整个栈,直接查表即可找到所有引用,枚举 GC Roots 的速度非常快。
第 6 章 安全点(Safepoint)——GC 暂停的合适位置
6.1 为什么不能在任意位置暂停
STW 需要暂停所有 Java 线程,但线程不能在任意位置被暂停——某些位置暂停是不安全的(OopMap 可能不准确,或者正处于原子操作的中间)。
JVM 的做法是:只在程序中特定的”安全点”(Safepoint)处暂停线程,在安全点上,JVM 保证 OopMap 是准确的,且不处于原子操作的中间状态。
6.2 安全点的设置原则
并非每一条字节码指令都是安全点——如果处处设置安全点,OopMap 数据会爆炸性增长(占用大量内存),且频繁更新 OopMap 有性能开销。
HotSpot 选择在以下特定位置设置安全点:
- 方法调用前后(调用某些方法时检查)
- 循环回跳(loop back-edge)处:防止长时间执行的循环迟迟无法到达安全点
- 异常抛出点
- JIT 编译代码中的特定指令:C2 在生成机器码时,在适当位置插入安全点轮询
安全点轮询(Safepoint Polling):
JVM 如何让所有线程知道”现在需要在安全点暂停”?不是中断信号,而是内存页技巧:
HotSpot 设置一个特殊的内存页(polling page)。正常运行时,这个页是可读的;当 JVM 需要所有线程进入安全点时,将这个页标记为不可读。
每个 Java 线程在经过安全点时,会尝试读这个内存页(安全点轮询指令,一条 test [address], eax):
- 如果可读(正常状态):读操作成功,线程继续执行
- 如果不可读(需要 STW):触发操作系统的段错误(SIGSEGV),JVM 的信号处理器捕获此信号,将线程挂起在安全点处等待 GC 完成
这种方式的开销极低:正常路径只是一条内存读指令,没有系统调用,没有锁。只有在真正需要 STW 时(触发段错误),才有额外开销。
6.3 主动式中断 vs 抢先式中断
到达安全点的方式有两种理论:
抢先式中断(Preemptive Suspension):GC 直接让操作系统中断所有线程,检查当前位置是否是安全点,不是则恢复执行让其继续跑到安全点。实现复杂,现代 JVM 基本不用。
主动式中断(Voluntary Suspension):设置一个全局标志,每个线程在到达安全点时主动检查这个标志(安全点轮询),如果标志表明需要暂停则线程自行挂起,等待 GC 完成后再恢复。HotSpot 使用此方式(通过内存页技巧实现主动检查,几乎零开销)。
6.4 到达安全点的延迟
STW 需要等所有线程都到达安全点才能开始 GC。如果某个线程正在执行一段没有安全点的长循环(如一个纯整数计算的内层循环,没有方法调用,没有回跳),它可能需要较长时间才能到达安全点,导致其他线程(已经在安全点等待)的等待时间变长。
JVM 参数 -XX:+PrintGCApplicationStoppedTime(JDK 8)或 -Xlog:safepoint(JDK 9+)可以打印 STW 停顿时间,其中包括”等待所有线程到达安全点”的时间(Time To Safepoint,TTSP)和”GC 实际执行时间”——如果 TTSP 很长,说明有线程迟迟到达安全点,通常是长循环或 JNI 调用。
第 7 章 安全区域(Safe Region)——睡眠线程的解决方案
7.1 安全点的局限性
安全点假设所有线程都在运行——线程在运行中会定期经过安全点,因此 JVM 可以等待所有线程到达安全点。
但有些线程不在运行:
- 阻塞在
sleep()或wait()中的线程:它们不在运行 Java 代码,无法执行安全点轮询 - 阻塞在 IO 等待中的线程:同上
对于这些不在运行的线程,JVM 不能让它们”主动跑到安全点”,怎么办?
7.2 安全区域的定义
安全区域(Safe Region) 是代码中的一段区间,在这段区间内,引用关系不会发生变化,因此 GC 可以在此期间随时安全地扫描引用。
典型的安全区域:线程阻塞在 Object.wait()、Thread.sleep()、Object.wait(long timeout) 中时,线程不会修改任何引用,整个阻塞期间都可以视为安全区域。
7.3 安全区域的工作流程
- 线程进入安全区域时,标记自己”处于安全区域中”(设置一个标志位)
- GC 发起 STW 时,无需等待处于安全区域中的线程——它们的引用状态是静止的,GC 可以直接将其视为”已在安全点”
- 当线程准备离开安全区域时,检查全局的安全点标志(或 JVM 是否正在 GC):
- 若否,线程直接恢复执行
- 若是,线程等待 GC 完成后才能离开安全区域
这样,即使有大量线程阻塞在 sleep()/wait() 中,GC 也不需要等待它们——它们已经标记为处于安全区域,不会影响 GC 的正确性。
第 8 章 finalize()——对象的”临终救赎”
8.1 对象的两次标记机会
可达性分析确定一个对象不可达后,它并不一定立即被回收。如果对象重写了 Object.finalize() 方法,JVM 会给它一次自救机会:
第一次标记:可达性分析后,不可达对象被第一次标记。JVM 检查它是否有必要执行 finalize()(覆盖了 finalize() 且此前未执行过)。有必要则将其放入 Finalizer 队列(java.lang.ref.Finalizer 的待处理队列)。
自救机会:Finalizer 线程(一个低优先级守护线程)异步地执行队列中对象的 finalize() 方法。如果对象在 finalize() 中将 this 赋值给某个强引用(如 SomeClass.instance = this),它就重新变得可达,从而逃脱本次回收。
第二次标记:下次 GC 时,已执行过 finalize() 的不可达对象被第二次标记,没有再次自救机会,直接回收。
8.2 为什么不应该使用 finalize()
finalize() 是 Java 中最臭名昭著的设计之一,JDK 9 将其标注为 @Deprecated,JDK 18 增加了 @Deprecated(forRemoval=true)(计划最终删除)。原因:
不确定性:JVM 不保证 finalize() 何时执行,甚至不保证一定执行(如 JVM 直接退出)。依赖 finalize() 做资源清理是不可靠的。
性能代价:有 finalize() 的对象在第一次 GC 时不能立即回收,需要放入 Finalizer 队列,由专用线程异步执行,至少多存活一个 GC 周期。大量有 finalize() 的对象会拖慢 GC。
安全隐患(Finalizer 攻击):可以通过在 finalize() 中”复活”对象,绕过构造方法中的安全检查(在异常抛出后,对象被 GC 但 finalize() 被调用,攻击者可在此注册引用)。
替代方案:使用 try-with-resources + AutoCloseable(确定性地关闭资源),或 PhantomReference + Cleaner(JDK 9+,替代 finalize() 的官方方案)。
第 9 章 总结
判断对象是否存活是 GC 工作的逻辑起点:
引用计数法:简单直觉,但无法处理循环引用,Java 未采用。
可达性分析:从 GC Roots(虚拟机栈引用、静态字段引用、JVM 内部引用等)出发遍历引用图,不可达的对象即为垃圾,天然解决循环引用问题。
四种引用类型:强引用永不回收,软引用 OOM 前回收,弱引用任意 GC 时回收,虚引用不阻止回收但提供回调通知——赋予程序员对 GC 行为的精细控制。
Stop-The-World:可达性分析需要引用关系冻结,否则会漏标存活对象。STW 是保证正确性的代价,也是 GC 延迟的根源。
安全点:线程只在特定代码位置(方法调用、循环回跳等)检查是否需要暂停,通过内存页不可读触发挂起,正常路径开销极低。
安全区域:为不在执行 Java 代码的线程(sleep/wait/IO 阻塞)提供的安全点扩展,使 GC 不需要等待阻塞线程。
OopMap:精确记录栈帧中的引用位置,使 GC 能在 STW 后快速准确地枚举所有 GC Roots。
下一篇 05 垃圾回收算法——标记清除、复制、标记整理与分代假说 将在”已知哪些对象是垃圾”的基础上,进入下一个问题:如何高效地回收这些垃圾——三种基础算法(标记-清除、复制、标记-整理)的原理、优缺点与适用场景。
参考文献
- Tim Lindholm et al., “The Java Virtual Machine Specification, Java SE 21 Edition”
- 周志明, 《深入理解 Java 虚拟机(第三版)》, 第 3.2 章:对象已死?
- Cliff Click, “Safepoints: Meaning, Side Effects and Implementations”, Azul Systems
- Aleksey Shipilev, “JVM Anatomy Quark #22: Safepoint Polls”, shipilev.net
- OpenJDK Wiki, “Garbage Collection Safepoints”, wiki.openjdk.org
- JEP 421: Deprecate Finalization for Removal (JDK 18)
思考题
- 可达性分析以 GC Roots 为起点遍历对象图。GC Roots 包括线程栈中的引用、静态变量、JNI 引用等。如果一个对象只被
ThreadLocal引用持有,且对应线程使用了线程池(线程不会销毁),这个对象会被 GC 回收吗?这是 ThreadLocal 内存泄漏的根本原因吗?- 安全点(Safepoint)是代码中 GC 可以安全暂停线程的位置。HotSpot 通常在方法调用、循环回跳等位置插入安全点。如果一个线程正在执行一个’可数循环’(如
for(int i=0; i<100; i++)),HotSpot 是否会在循环回跳处插入安全点?这与’不可数循环’(如while(true))有什么区别?这个行为曾导致过什么生产问题?finalize()方法允许对象在 GC 回收前’自救’(将自身重新挂到 GC Root 可达链上)。但finalize()只会被调用一次——如果对象第二次变为不可达,不会再调用finalize()。为什么 Java 官方从 JDK 9 开始将finalize()标记为 deprecated?Cleaner和PhantomReference相比finalize()有什么优势?