生活中的类比:彻底理解引用计数与可达性分析
为了更直观地理解这两个算法,我们通过生活中的常见场景进行类比。
1. 引用计数算法:图书馆借书模型
想象一个图书馆管理书籍的系统,每本书都有一个借阅计数器:
-
规则:
-
每当有人借书,计数器 +1。
-
还书时,计数器 -1。
-
当计数器归零时,书被下架(回收)。
-
-
场景:
-
学生 A 借了《算法导论》(计数器=1)。
-
学生 B 也借了这本书(计数器=2)。
-
学生 A 还书(计数器=1)。
-
学生 B 还书(计数器=0 → 书被下架)。
-
-
循环引用问题:
-
假设《算法导论》的参考文献是《数据结构》,而《数据结构》的参考文献也是《算法导论》。
-
两本书互相引用,但所有学生都已归还它们(计数器=1 → 无法归零)。
-
结果:这两本书永远无法下架,占据书架空间(内存泄漏)。
-
2. 可达性分析算法:社交网络清理僵尸账号
假设某社交平台要清理不活跃的账号:
-
规则:
-
从核心用户(明星、官方账号等)出发,标记所有粉丝和好友。
-
未被标记的账号视为“僵尸账号”,直接删除。
-
-
场景:
-
用户 A 和用户 B 互相关注,但他们没有关注任何核心用户。
-
平台从核心用户出发,遍历所有关联账号。
-
用户 A 和 B 无法从核心用户到达 → 标记为僵尸账号。
-
即使他们互相引用(关注),仍被删除。
-
-
对比引用计数:
-
如果使用“关注计数器”:
-
用户 A 和 B 的关注数均为 1(互相关注)。
-
即使无人真正与他们互动,也不会被清理。
-
-
可达性分析直接无视孤立的小圈子,只保留与核心关联的活跃用户。
-
十、终极总结:为什么 Java 选择可达性分析?
场景 | 引用计数(图书馆模型) | 可达性分析(社交网络模型) |
---|---|---|
实时性 | 立即下架归还的书 | 定期批量清理僵尸账号 |
循环引用处理 | ❌ 互相引用的书无法下架 | ✅ 孤立圈子直接清除 |
性能代价 | 管理员频繁更新计数器(琐碎操作) | 定期全面排查(短时集中资源) |
适用场景 | 小规模、实时性要求高的系统(如嵌入式) | 大规模、健壮性优先的系统(如 Java) |
Java 的设计哲学
-
牺牲实时性,换取健壮性:Java 面向企业级应用,内存泄漏的代价远高于短暂停顿。
-
自动化内存管理:开发者无需手动处理对象生命周期,专注业务逻辑。
-
高效处理复杂引用关系:现代应用对象关系复杂,可达性分析能可靠地清理垃圾。
十一、动手实验:验证垃圾回收行为
代码执行过程分析
public static void main(String[] args) { ReferenceCountingGC objA = new ReferenceCountingGC(); // 对象A被栈中局部变量objA引用(GC Root) ReferenceCountingGC objB = new ReferenceCountingGC(); // 对象B被栈中局部变量objB引用(GC Root) objA.instance = objB; // 对象A的字段指向对象B objB.instance = objA; // 对象B的字段指向对象A // 没有将objA和objB置为null System.gc(); // 触发垃圾回收 }
关键原因:对象仍被 GC Roots 引用
-
GC Roots 的定义
在垃圾回收时,JVM 会从一组称为 GC Roots 的起点出发,遍历所有可达对象。
GC Roots 包括:-
虚拟机栈中的局部变量(即当前方法中的
objA
和objB
)。 -
方法区中的静态变量。
-
本地方法栈中的 JNI 引用等。
-
-
对象可达性分析
-
在代码执行到
System.gc()
时,objA
和objB
未被置为null
,它们仍然是栈中的局部变量,属于 GC Roots。 -
即使
objA
和objB
互相引用,它们仍然可以通过 GC Roots 到达,因此会被标记为存活对象。
-
内存结构示意图
栈(Stack) 堆(Heap) ┌──────────────┐ ┌──────────────────────┐ │ 局部变量objA │ ──────────────────▶│ ReferenceCountingGC A │ │ │ │ - instance → B │ ├──────────────┤ └──────────▲───────────┘ │ 局部变量objB │ ───────────────────────────────┘ └──────────────┘ ┌──────────────────────┐ │ ReferenceCountingGC B │ │ - instance → A │ └──────────────────────┘
-
GC Roots(栈中的
objA
和objB
) 始终指向堆中的对象 A 和 B。 -
对象 A 和 B 互相引用,但它们仍然从 GC Roots 可达。
为什么不会被回收?
-
可达性分析的结果
-
从
objA
和objB
(GC Roots)出发,可以遍历到对象 A 和 B。 -
JVM 会标记这些对象为存活状态,不会回收它们。
-
-
循环引用不影响可达性分析
Java 的垃圾回收器通过可达性分析算法(而非引用计数),只要对象从 GC Roots 可达,即使存在循环引用,也不会被回收。
对比实验:置为 null
后的回收
public static void main(String[] args) { ReferenceCountingGC objA = new ReferenceCountingGC(); ReferenceCountingGC objB = new ReferenceCountingGC(); objA.instance = objB; objB.instance = objA; objA = null; // 切断局部变量对对象A的引用 objB = null; // 切断局部变量对对象B的引用 System.gc(); // 对象A和B将被回收 }
-
结果:内存被回收。
-
原因:
objA
和objB
被置为null
后,堆中的对象 A 和 B 不再被 GC Roots 引用,虽然它们互相引用,但无法从 GC Roots 到达,因此被判定为垃圾。
验证方法:观察 GC 日志
-
添加 JVM 参数
java -XX:+PrintGCDetails ReferenceCountingGC
-
不置为
null
时的日志[GC (System.gc()) [PSYoungGen: 1318K->488K(38400K)] 1318K->496K(125952K), 0.0011736 secs]
-
内存未释放:堆内存从 1318KB 降至 496KB,但对象 A 和 B 占用的 2MB 内存未被回收。
-
-
置为
null
后的日志:[GC (System.gc()) [PSYoungGen: 1318K->0K(38400K)] 1318K->210K(125952K), 0.0011736 secs]
-
内存释放:堆内存从 1318KB 降至 210KB,对象 A 和 B 被回收。
-
总结
-
Java 的垃圾回收依赖可达性分析,而非引用计数。
-
只要对象被 GC Roots 引用(如局部变量),即使存在循环引用,也不会被回收。
-
切断 GC Roots 的引用后,循环引用的对象会被正确回收。
通过生活中的类比和动手实验,相信你已经彻底理解引用计数与可达性分析的核心区别。关键在于:Java 通过“根搜索”无视孤立对象的内部引用,从根本上杜绝循环引用导致的内存泄漏。
💡 你的每个关注,都是我们共同进步的燃料!
⬇️ 下期预告:《每天分享一个小知识——JVM之强应用、弱引用、虚引用、软引用》