1. 概述
本文将深入探讨 JVM 是如何处理那些不可达但存在循环引用的对象回收问题。
我们会先简要回顾几种常见的垃圾回收(GC)算法,然后重点分析 JVM 是如何解决循环引用带来的内存泄漏风险的。
需要特别说明的是,垃圾回收机制本身并不属于 JVM 规范的强制要求,而是由 JVM 实现方自行决定(参考 JVM 规范文档)。这意味着不同的 JVM 实现可能采用完全不同的 GC 策略,甚至不实现 GC。
本文聚焦于 HotSpot JVM 的实现细节。后文中提到的 JVM,如无特别说明,均指 HotSpot JVM。
2. 引用计数法
引用计数是一种直观的垃圾回收策略:每个对象维护一个“引用计数”,记录当前有多少变量指向它。✅ 只要引用数大于 0,对象就被视为存活。
这个计数通常存储在对象头(Object Header)中。在最朴素的实现中:
- 每当有新引用指向该对象,计数器原子性地 +1
- 每当引用被清除或重置,计数器原子性地 -1
- 当计数归零时,对象立即被回收
比如 Swift 语言就采用了引用计数(ARC)来管理内存。❌ 但 JVM 并没有使用引用计数作为其 GC 算法的基础。
2.1 优缺点分析
✅ 优点:
- 内存释放即时:对象一旦无引用,立刻回收,避免长时间停顿(GC pause)
- 回收成本分散:不会出现集中式的“Stop-The-World”暂停
⚠️ 缺点:
- 原子操作开销大:频繁的增减操作在多线程环境下性能损耗明显
- 存在优化手段:如延迟计数(deferred)、缓冲计数(buffered)等可缓解问题
❌ 致命缺陷:无法处理循环引用
举个典型例子:对象 A 引用 B,B 又引用 A。当整个结构脱离主对象图后,虽然逻辑上已不可达,但由于彼此引用,计数始终 ≥1,导致内存泄漏。
这在实际开发中并不少见,比如双向链表就是典型场景。
假设一开始有一个外部对象 D 持有链表头引用:
此时链表可达,不应被回收,引用计数正确。
但当 D 被置为 null 或超出作用域后,链表整体已不可达:
问题来了:尽管链表已“死亡”,但节点间的相互引用使得引用计数无法归零。在纯引用计数机制下,这些对象将永远无法被回收 —— 踩坑预警!
3. 追踪式垃圾回收(Tracing GC)
与引用计数不同,追踪式 GC 不关心“有多少人引用你”,而是问:“你能从根上找到吗?”
✅ 核心思想:从一组已知存活的“GC Roots”出发,沿引用链遍历整个对象图。所有能被访问到的对象标记为存活,其余不可达对象即为垃圾。
其工作流程类似于经典的三色标记算法:
- 初始:所有对象为白色(未访问)
- 从 GC Roots 开始遍历,灰色表示正在处理
- 遍历完成后,仍为白色的对象即为垃圾
示意图如下:
3.1 什么是 GC Roots?
GC Roots 是 JVM 明确认定为“绝对存活”的对象,主要包括:
- ✅ 当前栈帧中的局部变量(包括方法参数)
- ✅ 正在运行的线程(Thread 对象)
- ✅ 所有静态变量(static fields)
- ✅ 由系统类加载器加载的类(Class 对象)
- ✅ JNI 全局/局部引用
3.2 优势:天然免疫循环引用
由于追踪式 GC 依赖可达性分析,而不是引用数量,因此 循环引用不会成为障碍。
哪怕对象之间形成复杂环路,只要整体无法从 GC Roots 到达,就会被正常回收。上图中即使 B ↔ C 相互引用,只要 A 不可达,整个子图都会被清理。
这也是为什么现代 JVM GC 都采用追踪机制的重要原因。
3.3 HotSpot JVM 的实现
截至当前版本,HotSpot JVM 的所有 GC 实现均为追踪式收集器,包括:
- ✅ CMS(Concurrent Mark-Sweep)
- ✅ G1(Garbage-First)
- ✅ ZGC(Z Garbage Collector)
- ✅ Shenandoah
✅ 结论明确:Java 开发者无需担心循环引用导致的内存泄漏问题 —— JVM 已经帮你搞定。
💡 补充知识:有些语言(如 Python)采用“引用计数 + 追踪 GC”混合模式,用追踪 GC 定期清理循环引用。而 JVM 从一开始就选择了更彻底的追踪路线。
4. 总结
- ❌ 引用计数无法解决循环引用,不适合 JVM 的设计目标
- ✅ HotSpot JVM 使用追踪式垃圾回收器,通过 GC Roots 可达性分析判断对象生死
- ✅ 循环引用在 JVM 中不会导致内存泄漏,GC 会正常回收不可达的对象图
- 📚 如需深入理解 GC 原理,推荐阅读《The Garbage Collection Handbook》
简单粗暴地说:放心写双向引用,JVM 懂你。