1. 从一个内存泄漏现场说起:为什么需要可达性分析
前阵子帮一个团队排查线上服务内存持续增长的问题,翻了一下午堆转储文件,发现一个看似偶然的规律:出问题的对象,全都是被某个静态集合持有的。它们并不是没人用,而是被一个"看似早就该结束"的上下文对象一路引用着。那一刻我意识到,理解垃圾回收里的可达性分析,不只是在面试时背诵概念,它直接决定你能不能在三分钟里定位线上OOM。
简单说,可达性分析解决的核心问题是:怎么判断一个对象还"活着"? JVM不像C++需要手动释放内存,GC承担了找出哪些对象可以被安全回收的责任。而"能不能回收"的判断依据,不是它是否还被变量引用,而是从一组称为GC Roots的根节点出发,能不能沿着引用链找到它。如果能找到,说明它还活着;找不到,说明它已经与世隔绝,可以被回收。
这套判定逻辑和Python里常见的引用计数机制完全不同。Python的垃圾回收主要依赖每个对象维护一个计数器,引用增加计数加一,减少就减一,归零就回收。听起来直观优雅,但有一个绕不过去的硬伤——循环引用。两个对象互相持有对方的引用,计数器永远不为零,内存就永远得不到释放。虽然Python后来引入了标记-清除和分代回收来辅助处理循环引用,但核心路径仍然是引用计数。
JVM之所以选择可达性分析,核心原因有两条:一是它天然免疫循环引用问题,两个互相引用的对象只要整体从根上不可达,照样被GC收走;二是它不需要在每个对象上维护计数器,避免了频繁的原子操作带来的性能损耗。很多从Python转到Java的人容易产生一个错觉:对象赋值为null就会被回收。实际上,只有当你把所有指向它的引用路径都切断,让GC Roots无法再触达它时,它才会进入回收流程。这几个字之间的差距,就是无数内存泄漏事故的温床。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从GC Roots出发的引用链:可达性判定到底怎么走
我见过不少开发者对可达性分析的理解停留在"从根节点遍历图"这个层面,但一到实际分析堆转储就两眼一抹黑。要真正掌握它,需要把判定逻辑拆成三个层次:根是什么,边是什么,以及怎么走。
2.1 根节点集合与引用链的完整过程
GC Roots是垃圾回收中一切可达性遍历的起点集合。JVM从这些根出发,沿着对象之间的引用关系向外扩散,能触及到的对象标记为"存活",触及不到的视为"可回收"。这个过程在HotSpot里的实现可以类比成在一张巨大的有向图上做遍历,每个对象是图上的节点,每个引用字段就是一条有向边。
注意,这里的遍历并不只扫描堆本身。GC Roots中有一部分在堆外(比如虚拟机栈里的局部变量),它们指向堆内的对象,相当于图的"入口"。遍历过程中,JVM会把这些根对象直接引用的对象加入待处理集合,然后不断从集合中取出对象,扫描其所有字段引用,把新发现的未访问对象继续入队,直到集合清空,整个可达集合就确定下来了。
这个过程的时间复杂度近似于O(可达对象数+引用边数),意味着堆越大、存活对象越多,标记耗时越长。这也是为什么现代垃圾回收器都在拼命优化标记阶段的并发性和增量性——纯串行地遍历一个几十GB的堆,停顿时间会让人怀疑服务器是不是死机了。
2.2 四种引用类型在可达性分析中的不同命运
从JVM规范的角度,Java从1.2版本开始引入了四种引用类型,它们在可达性分析中的处理策略完全不同,直接影响了对象被回收的时机。
- 强引用(Strong Reference):最常见的
Object obj = new Object(),只要强引用存在,GC就永远不会回收该对象,即使它已经"事实上没用"。这也是内存泄漏最常见的来源——静态集合持有本该释放的对象。 - 软引用(SoftReference):当内存充足时,指向的对象不会被回收;当内存即将溢出时,JVM会尝试回收只被软引用指向的对象,以换取释放空间。软引用适合做缓存,比如图片缓存、大对象缓存。
- 弱引用(WeakReference):比软引用更"短命"。只要发生GC,被弱引用指向、并且没有其它强引用链的对象就会被回收,哪怕内存完全够用。像
WeakHashMap、ThreadLocal的ThreadLocalMap的key,都是利用弱引用来防止内存泄漏。 - 虚引用(PhantomReference):最弱的一种,你甚至无法通过它获取对象的实例。它存在的唯一意义是:当对象被回收时,收到一个系统通知,常用于堆外内存、直接内存的回收追踪。
在可达性分析的语境里,只有强引用会影响对象的"可达"状态。软引用、弱引用、虚引用指向的对象,如果只有这些弱级别的引用而没有强引用链,它们就是"软可达""弱可达""虚可达"状态,会被安排在不同时机回收。
2.3 finalize机制:可达性分析结果不是最终判决
这里埋着一个很多老司机也容易忽略的坑:一个从GC Roots不可达的对象,并不会立刻被回收。JVM在判定对象不可达后,会检查它是否重写了finalize()方法,并且是否尚未执行过。如果满足条件,对象会被放入一个低优先级的Finalizer队列,由专门的线程去调用finalize()。
在finalize()执行期间,对象如果重新与某个GC Roots建立引用(比如把自己赋值给一个静态变量),它就能"死而复生",躲过这一次回收。但这种做法极度不推荐,依赖finalize()来释放资源本就是历史遗留的坏味道,用它来救活对象只会制造难以排查的行为异常。正确的兜底方式是使用try-with-resources或Cleaner机制。
3. GC Roots具体有哪些:一张清单和一个排查技巧
很多人背得出"栈、静态变量、JNI引用"这几类GC Roots,但真正用MAT或者jheap分析问题时,看着Dominator Tree上那一排排节点,依然分不清根源到底长在哪。这一节把GC Roots的常见构成列清楚,同时说说怎么利用它们去定位实际问题。
3.1 虚拟机栈、本地方法栈与方法区中的根
GC Roots的典型构成大致分这么几类:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。每个正在执行的方法,其局部变量、参数、临时对象都有可能被当作根。
- 本地方法栈中JNI(Native方法)引用的对象,包括JNI局部引用和全局引用。
- 方法区中类的静态属性引用的对象。一个
static字段持有某个对象,就等于把这个对象变成了"根上长出的树",除非静态字段被置空,否则树上的所有对象都不回收。 - 方法区中常量池里引用的对象,比如字符串常量
"hello"对应的String对象,这也就是为什么String.intern()返回的对象很难被回收。 - 所有被
synchronized持有的锁对象。 - 当前活跃的Java线程对象本身。
这里面最隐蔽的是线程对象。一个活着的线程,即使处于WAITING或TIMED_WAITING状态,它的线程对象以及线程栈中引用的所有对象,都是可达的。用线程池不当、线程没有正常结束,会连带拖住一大堆业务对象无法回收。
3.2 实际排查时如何沿着GC Roots走引用链
拿到一个几十MB的堆转储文件时,我一般不会直接去找"谁大",而是先在MAT里用"Path to GC Roots"功能,选中最可疑的对象,让它展示从根到这个对象的完整引用链。这个过程就相当于让工具帮我们倒着重走一遍可达性分析。
举个例子。一次定位到某个缓存Map占了几百MB,但业务上说这个缓存的key早该过期了。用Path to GC Roots一看,引用链是:Thread -> ThreadLocal.ThreadLocalMap -> Entry.value -> BigObject。原因立刻清楚:ThreadLocal的value是强引用,线程还活着,Map就永远可触达。这就是经典的ThreadLocal内存泄漏场景,根源就是GC Roots中的线程对象不死,连带所有引用都不死。
再看另一个常见现场:ClassLoader -> Class -> static字段 -> Object。典型的因为类加载器无法卸载导致静态区持有的缓存无法释放。这种问题在热部署、插件化场景尤其常见,本质都是静态引用的根太深。
4. 三色标记法:并发垃圾回收器如何实现可达性分析
如果说可达性分析的原理在串行GC中还算直白,那现代垃圾回收器(CMS、G1、ZGC)为了缩短STW(Stop-The-World)时间,在标记阶段引入了三色标记法,这才真正把可达性分析的复杂度拉高了一个量级。
4.1 白灰黑三色的标记过程
三色标记法把对象在标记过程中的状态分为三种:
- 白色:尚未被访问到。标记结束后仍为白色的对象,会被判定为不可达,等待回收。
- 灰色:对象自身已被访问到(已确认可达),但其引用字段还没完全扫描完毕。灰色对象是遍历的工作队列。
- 黑色:对象及其所有引用字段都已被扫描完毕。黑色对象是最终确认存活的。
整个标记过程就是一个"灰色对象不断变黑、白色对象不断变灰"的扩散过程。开始时所有对象都是白色,从GC Roots出发把它们直接引用的对象标灰,然后逐个处理灰色对象:扫描它的引用字段,把引用的白色对象标灰,自己标黑。如此循环,直到没有灰色对象为止。剩下的白色对象,就是不可达对象。
4.2 并发标记的致命问题:漏标与错标
如果在标记过程中业务线程完全不暂停,一边标记一边有新引用产生、旧引用断开,就会出现两类一致性问题。
**漏标(错标为白色)**是最危险的。当黑色对象(已经扫描完引用的对象)被业务线程重新赋值,指向了一个还处于白色的对象时,由于黑色对象不会再被扫描,这个白色对象虽然被引用了,却依然被判为不可达,最终被垃圾回收器误收。
**错标(应该标黑却标不黑)**相对温和,只是导致对象无法回收(浮动垃圾),顶多让GC多跑几轮,不会产生致命后果。
问题核心落在:标记线程和业务线程并发时,如何保证不出现漏标。业内两条路线——CMS采用增量更新,G1采用原始快照(SATB)。
增量更新的思路是:当黑色对象新增了对白色对象的引用时,记录下这个引用关系,在标记快结束时重新扫描黑色对象,补上漏掉的白色节点。原始快照的思路则是:在并发标记开始时记录当时所有存活对象的快照,之后即使引用被切断,只要在快照里存在过的对象,就不会在这一轮中被回收。
这两种策略都不是完美无缺的。增量更新会在标记尾部多一次重新扫描,带来额外开销;原始快照则会保留一部分已经不被引用的"死对象"在堆里,产生浮动垃圾,只能等下一轮GC清理。理解这个取舍,对后续调整G1、CMS参数非常关键。
4.3 写屏障:实现记忆与拦截的关键机制
要让增量更新或原始快照发挥作用,JVM必须在每次引用赋值时都能感知到变化。这个感知机制就是写屏障(Write Barrier),它在引用类型字段赋值的前后插入一段逻辑,比如把新引用记录到"待重扫"集合,或者把旧引用记录到"快照集合"。
写屏障不是Java代码层面的东西,而是由即时编译器在编译阶段插入的伪代码逻辑。它的代价主要不在执行指令本身,而在同步、记录集合的维护开销。理解了写屏障在标记中的作用,你在设置-XX:+CMSScavengeBeforeRemark这类参数时,也就明白为什么要在重新标记前做一次年轻代收集——把并发标记阶段新产生的、记录下来的引用关系尽量处理掉,减少重新标记时的扫描压力。
5. 跨代引用与记忆集:可达性分析在分代GC中的修正
可达性分析的原则在理论上很清晰:从根出发遍历全堆。但在一个几十GB的堆里每次GC都全堆标记,代价实在太大。于是分代收集思想出现:把所有对象按存活时间划分到年轻代、老年代,不同代采用不同回收频率。但分代引入了一个新问题——跨代引用。
5.1 为什么需要记忆集和卡表
假设老年代有对象A引用了年轻代对象B。做年轻代GC(Minor GC)时,如果只扫描年轻代里的根,B就会被判定为不可达而错误回收。可如果为了照顾跨代引用每次都扫描整个老年代,年轻代GC快速回收的优势就丧失殆尽。
解决方案是引入一个结构记录老年代中的哪些区域存在指向年轻代的引用,这个结构就是记忆集(Remembered Set)。年轻代GC时,只需要扫描记忆集中记录的区域,把这些区域里的对象当作GC Roots的补充,就能准确捕捉跨代引用,而不必扫描整个老年代。
HotSpot实现记忆集通常采用**卡表(Card Table)**方式。把堆划分成一个个512字节的内存块,每个块对应卡表中的一字节。当某个卡块内存中的对象产生了指向年轻代的引用,就把对应卡标脏(字节置1)。GC扫描时,只需扫描脏卡覆盖的对象,效率提升巨大。
5.2 卡表维护与伪共享问题
卡表的维护同样依赖写屏障。每次发生引用赋值时,如果赋值后的对象住址落在老年代区域,就要将对应的卡标为脏。这里有个经典性能陷阱:伪共享(False Sharing)。卡表本身是一块连续内存,多线程并发赋值时,如果多个线程同时操作相邻卡位,即使操作的是不同的卡,也可能因为CPU缓存行(通常64字节)被共享而触发缓存同步风暴,拖慢整体吞吐。
HotSpot在JDK 8u之后引入了卡表写屏障的批量处理、加上-XX:+UseCondCardMark参数按条件标记卡表,在一定程度上缓解伪共享问题。排查GC日志时,如果发现Young GC时间异常长,且ReduceInitialCardMarks没有生效,多看看这个角度。
5.3 G1与ZGC对可达性分析边界的改变
G1虽然仍然基于卡表,但它把堆划分为多个Region,GC Roots的扫描深度变得区域化。G1的回收集(CSet)只包含部分Region,并发标记阶段记录每个Region的可达比例,之后根据收益优先回收"垃圾最多的Region"。这等于把可达性分析从一个"全堆布尔值"问题,升级成了"可以按区域计算的收益评估问题"。
ZGC更进一步,引入染色指针(Colored Pointer)与读屏障(Load Barrier),在指针里直接携带三色标记状态信息。对象不再依赖卡表来判断可达性,而是通过读屏障在访问引用时即时修正颜色状态。这使得ZGC能用极度短暂的停顿完成大规模堆的回收。理解这些差异有助于在选型时判断该用哪款收集器、怎么调整堆大小——而不只是记住"G1是JDK11默认"这类表面答案。
6. 实测:用一段代码和一次堆转储把可达性分析“跑”出来
说了这么多原理,如果不动手验证一遍,理解始终浮在表面。我建议你用下面这个方法,亲手制造一次内存泄漏,再通过堆转储反推引用链,把可达性分析的整个过程“跑”出来。
6.1 故意制造一个不可达却活着的对象
写一段简单程序,在方法里创建对象并放入静态Map,然后让方法退出,局部变量失效。此时对象本身已经"业务不可达",但由于静态Map仍然持有强引用,GC Roots依然能触达它。
java复制import java.util.HashMap;
import java.util.Map;
public class ReachabilityDemo {
private static final Map<String, byte[]> leakBucket = new HashMap<>();
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 500; i++) {
byte[] payload = new byte[1024 * 1024]; // 1MB
leakBucket.put("item-" + i, payload);
Thread.sleep(20);
}
// 这里局部变量payload已经失效,但对象仍被静态Map持有
System.out.println("done, heap data is still reachable via static map");
Thread.sleep(600_000); // 保持进程存活,方便dump
}
}
用-Xmx256m启动,跑完500次循环后堆基本占满。jmap -dump:format=b,file=leak.hprof PID抓取堆转储,然后用MAT打开,选Overview里的Histogram,按Shallow Heap排序,能看到那个1MB大小的byte[]实例有500份还留在堆里。
6.2 用Path to GC Roots重走引用链
在MAT里选一个byte[]对象,右键Path to GC Roots -> with all references,展开结果,你会清清楚楚看到一条链:main线程 -> ReachabilityDemo的静态字段leakBucket -> HashMap内部的Node数组 -> Node.value -> 这个byte[]。
这一步实际就把可达性分析的"从根出发沿引用链标记"复现了一遍。注意MAT展示的是引用链方向的反向追踪——从对象出发倒推回根的路径,这正是GC判断对象可达时所用路径的逆过程。只要这条路存在,GC就绝不会回收它。
6.3 验证不可达:切断引用后看GC日志
把代码改掉,在循环结束后加上leakBucket.clear(),再启动时加上GC日志参数-Xlog:gc*,你会看到GC日志里的堆占用迅速回落到基线水平,那个1MB的byte[]数组被完整回收。这一个操作就演示了可达性分析的核心:引用链断掉,可达性立刻消失,GC马上就发现。
我建议你在自己的测试环境里跑一遍这个实验,配合MAT走几次Path to GC Roots。等技术社区里讨论"可达性分析"时,你就能从字母概念切换到内存图景:所谓可达,就是一个从根出发、顺着引用一路走到底的路径问题;所谓泄漏,就是这条本不该存在的路径一直没有被切断。
