04 垃圾回收基础——可达性分析、安全点与安全区域

摘要:

GC 要回收内存,首先必须解决一个核心问题:哪些对象是”垃圾”,哪些是”存活”的? 这个看似简单的问题,背后隐藏着深刻的工程挑战。本文从两种判断对象存活的算法出发——引用计数法(简单直觉,但无法处理循环引用,被 Java 放弃)与可达性分析法(从 GC Roots 出发遍历引用图,是 HotSpot 的选择),深入剖析 GC Roots 的精确范围(哪些对象是根),以及 Java 四种引用类型(强/软/弱/虚引用)与 GC 行为的关系。然后进入更难的工程问题:可达性分析必须在一个一致性快照下进行(否则分析过程中对象关系的变化会导致存活对象被误回收),这要求所有 Java 线程暂停(Stop-The-World)——而让所有线程在合适的位置安全暂停,正是安全点(Safepoint)安全区域(Safe Region) 机制要解决的问题。


第 1 章 垃圾的定义:从直觉到工程严格化

1.1 什么是”垃圾”

在 Java 中,垃圾(Garbage) 的定义是:不再被任何活跃代码所引用、因此不可能再被程序访问的对象。换句话说,是那些”已经没有人需要、但仍然占着内存”的对象。

这个定义听起来很直观,但工程实现时有一个本质挑战:JVM 如何在程序运行过程中,高效且准确地判断哪些对象符合这个定义?

两种思路:

  1. 引用计数(Reference Counting):为每个对象维护一个”被引用次数”计数器,计数降为 0 时即为垃圾。
  2. 可达性分析(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.TYPEint.class 等)
  • 常驻的系统类(java.lang.Stringjava.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):本身已标记为存活,且其所有引用的对象也都已扫描完毕。黑色对象是”已完全处理”的存活对象。

分析过程:

  1. 将所有 GC Roots 标记为灰色,放入工作队列
  2. 取出一个灰色对象,扫描它的所有引用字段:将白色的引用对象标记为灰色;将当前对象标记为黑色
  3. 重复步骤 2,直到工作队列为空(没有灰色对象)
  4. 分析结束:黑色 = 存活,白色 = 垃圾

三色标记为并发 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 会抛出错误而不是回收强引用对象)。

“消除”强引用:将引用变量赋值为 nullobj = 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();  // 可能返回 null

GC 行为无论内存是否充足,只要 GC 运行,弱引用的对象就会被回收(前提是没有强引用指向该对象)。

典型用途

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() 返回典型用途
强引用直接赋值永不(只要引用存在)对象本身绝大多数场景
软引用SoftReferenceOOM 前对象或 null内存敏感缓存
弱引用WeakReference任意 GC对象或 nullWeakHashMapThreadLocalMap.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 安全区域的工作流程

  1. 线程进入安全区域时,标记自己”处于安全区域中”(设置一个标志位)
  2. GC 发起 STW 时,无需等待处于安全区域中的线程——它们的引用状态是静止的,GC 可以直接将其视为”已在安全点”
  3. 当线程准备离开安全区域时,检查全局的安全点标志(或 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 垃圾回收算法——标记清除、复制、标记整理与分代假说 将在”已知哪些对象是垃圾”的基础上,进入下一个问题:如何高效地回收这些垃圾——三种基础算法(标记-清除、复制、标记-整理)的原理、优缺点与适用场景。


参考文献

  1. Tim Lindholm et al., “The Java Virtual Machine Specification, Java SE 21 Edition”
  2. 周志明, 《深入理解 Java 虚拟机(第三版)》, 第 3.2 章:对象已死?
  3. Cliff Click, “Safepoints: Meaning, Side Effects and Implementations”, Azul Systems
  4. Aleksey Shipilev, “JVM Anatomy Quark #22: Safepoint Polls”, shipilev.net
  5. OpenJDK Wiki, “Garbage Collection Safepoints”, wiki.openjdk.org
  6. JEP 421: Deprecate Finalization for Removal (JDK 18)

思考题

  1. 可达性分析以 GC Roots 为起点遍历对象图。GC Roots 包括线程栈中的引用、静态变量、JNI 引用等。如果一个对象只被 ThreadLocal 引用持有,且对应线程使用了线程池(线程不会销毁),这个对象会被 GC 回收吗?这是 ThreadLocal 内存泄漏的根本原因吗?
  2. 安全点(Safepoint)是代码中 GC 可以安全暂停线程的位置。HotSpot 通常在方法调用、循环回跳等位置插入安全点。如果一个线程正在执行一个’可数循环’(如 for(int i=0; i<100; i++)),HotSpot 是否会在循环回跳处插入安全点?这与’不可数循环’(如 while(true))有什么区别?这个行为曾导致过什么生产问题?
  3. finalize() 方法允许对象在 GC 回收前’自救’(将自身重新挂到 GC Root 可达链上)。但 finalize() 只会被调用一次——如果对象第二次变为不可达,不会再调用 finalize()。为什么 Java 官方从 JDK 9 开始将 finalize() 标记为 deprecated?CleanerPhantomReference 相比 finalize() 有什么优势?