1. 从一次线上告警说起:为什么你绕不开可达性分析
做Java开发的人,基本都遇到过这类场景:一台4核8G的机器,部署的应用刚上线时GC表现还很正常,结果跑了两个星期,老年代占用一路走高,Full GC越来越频繁,接口响应时间从几十毫秒飙到几秒,CPU还时不时打满。你查了堆栈、查了SQL、查了缓存,最后发现是某个静态Map把不该放的对象一直引用着,GC怎么回收都回收不掉。这时候你才想起来,JVM判断一个对象“该不该回收”,靠的正是可达性分析算法(Reachability Analysis)。
我第一次完整搞懂这个概念,也是因为一次线上事故。当时排查了很久,内存泄漏的根源是一个单例对象里存了用户Session,而Session里又挂着大对象,导致整个引用链上的对象全部成了GCRoot的“亲戚”,永远清理不掉。从那以后我就意识到,搞懂可达性分析,不只是面试背题,而是每个Java开发者排查内存问题、优化GC参数、甚至设计缓存和连接池时,都必须具备的基础功。
这篇文章想做的事很简单:把可达性分析算法从头到尾讲透。包括它是怎么诞生的、GC Roots到底是什么、三色标记怎么工作、CMS和G1在处理并发标记时各自用了什么黑科技,以及我在实际排查问题中总结的一些经验。全程尽量说人话,能用例子讲清楚的地方绝不放公式糊弄。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 可达性分析的来龙去脉:为什么不用引用计数法
2.1 引用计数法的“阿喀琉斯之踵”
在可达性分析之前,JVM早期方案和不少脚本语言(比如Python早期版本、PHP)用的是引用计数法(Reference Counting)。原理非常直观:每个对象维护一个计数器,被引用时加1,引用失效时减1,计数器归零就回收。这方案实现简单、执行高效,看起来Perfect。
但它的致命缺陷在对象循环引用时彻底暴露。举个例子:
java复制public class Node {
private Node next;
}
Node a = new Node();
Node b = new Node();
a.next = b;
b.next = a;
a = null;
b = null;
现在a和b都被置空了,但a的next还指着b,b的next也指着a,两个对象的引用计数都不是0。引用计数法直接傻眼,这两个对象就成了永远回收不掉的“僵尸对象”,只能等进程退出。如果这种环状引用出现在大型业务系统里,内存泄漏就是必然。
题外话:Python后来用gc模块做辅助回收,本质就是因为纯引用计数没法解决循环引用。Java从设计之初就抛弃了这条路,选择了更彻底的可达性分析。
2.2 可达性分析的核心思想:从“根”出发的图遍历
可达性分析的核心思路非常朴素——从一组称为GC Roots的根对象出发,沿着引用链向下搜索,所有能被遍历到的对象标记为“存活”,遍历不到的对象就是“可回收”。
说人话:JVM把整个堆内存看成一张有向图,对象是图的节点,引用关系是图的边。每次GC时,JVM从一个固定的入口集合出发,做一次图的遍历。遍历到的对象都活着,没遍历到的就是垃圾。
这里有个和引用计数法完全不同的思维转变:引用计数法是“统计每个对象有多少人指着它”,可达性分析是“从源头出发看哪些对象能被找到”。前者是局部视角,后者是全局视角。全局视角天然免疫循环引用——两个互相引用的对象,只要没有外部根引用它们,在图上就是孤岛,照样被回收。
2.3 “可达”的判定标准远比想象中严格
很多人以为“可达”只是一个非黑即白的布尔值,其实JVM对可达状态的定义相当细腻。按《Java虚拟机规范》和HotSpot的实现,对象大体分五种状态:
- 强可达(Strongly Reachable):从GC Roots出发,不经过任何引用类型就能访问到。普通变量赋值就是这种情况,GC死活不会回收。
- 软可达(Softly Reachable):从GC Roots出发,必须经过SoftReference才能访问到。内存充足时留着,内存紧张时优先回收,适合做缓存。
- 弱可达(Weakly Reachable):从GC Roots出发,必须经过WeakReference才能访问到。下一次GC必然被回收,适合做防止内存泄漏的辅助结构。
- **虚可达(Phan
