1. 从“谁还活着”说起:为什么 JVM 需要可达性分析
写 Java 的人都知道 JVM 会自动回收内存,但真正琢磨过“JVM 怎么判断一个对象该不该回收”的人并不多。大部分时候我们只需要 new 对象、用对象,完全不用操心释放的事。可一旦你开始排查内存泄漏、调整 GC 参数、或者看那些几百 MB 的堆转储文件时,就会发现一个绕不开的问题:JVM 到底凭什么说一个对象是“垃圾”?
早期语言(比如 C/C++)靠手动释放,Java 则把这个脏活累活交给了垃圾回收器。而垃圾回收器要干活,第一件事就是搞清“哪些对象还活着、哪些已经死了”。这就引出了本文的核心主题——可达性分析算法。它不是什么高深莫测的理论,而是所有主流 JVM 垃圾回收器(Serial、Parallel、CMS、G1、ZGC)都在用的生死判定标准。
简单来说,可达性分析干的事情就是:从一个固定的起点集合出发,沿着对象引用关系一路走,凡是能走到的对象就认定为“活着”,走不到的统统视为“可回收”。 这个起点集合就是大名鼎鼎的 GC Roots。
可能你会问:为什么不用“引用计数”这种更直观的方案?引用计数是每个对象维护一个计数器,被引用一次就加一,失效就减一,归零就回收。听着挺合理,但它解决不了一个致命问题——循环引用。比如 A 引用 B、B 引用 A,两个对象已经没有任何外部引用了,但它们的计数器还互相挂着,永远归不了零。可达性分析从根出发扫描,天然绕开了这个问题,这也是 JVM 最终选择它的根本原因。
这篇文章会从 GC Roots 的具体构成讲起,拆解三色标记算法、读写屏障、增量更新与原始快照这些底层机制,再结合 CMS 和 G1 的实际处理过程做串讲,最后聊几个我实际排查 GC 问题时踩过的坑。适合对 JVM 内存管理有一定了解、想深入理解 GC 日志里那些“为什么 Stop The World”的读者,也适合正在准备面试、想把这些机制讲清楚的人。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 可达性分析的完整逻辑:从根出发的“引用地图”
2.1 核心定义与判定标准
可达性分析的判定标准用一句话概括就是:对象到 GC Roots 之间是否存在一条完整、未被中断的引用链。 存在,那就是活的;不存在,那就是死的,随时可以被回收。
这有点像你在一个园区里找人——GC Roots 是园区的几个固定入口,引用链是园区里的道路。你从所有入口出发,沿着道路能走到的每个房间都有人住;那些和任何入口都不连通的片区,就是没人住的空房,可以拆掉重盖。关键点在于:判断依据不是“这个房间有没有人”,而是“从入口能不能走到这个房间”。
这个思路有个很重要的推论:不可达对象不一定会被立即回收。 它只是被标记为“可回收”,真正什么时候动它,取决于垃圾回收器的执行时机。另外,一个对象在 finalize() 方法里还有机会把自己重新“救活”——比如在 finalize() 里把自己赋值给某个静态变量,这样它又重新和 GC Roots 建立了引用链,逃过一劫。不过 finalize() 机制在现代 JVM 中早已不推荐使用,而且一个对象的 finalize() 最多被执行一次,指望靠它保命完全是不靠谱的做法。
JVM 在实现可达性分析时,并不是无限期地进行深度遍历——底层用的是类似图遍历的算法,从一个对象找到它所有引用的对象,再继续往下找,直到所有路径都被走完。这个过程如果用递归实现很容易爆栈,所以 JVM 内部用 OopMap 记录栈上和寄存器中的引用位置,配合遍历栈帧来完成整个扫描。这个细节后面聊安全点时会再提到。
2.2 为什么是图遍历而不是逐个标记
有人可能会想:那我给每个对象加一个“是否可达”的布尔标记,挨个检查引用关系不就行了?理论上可以,但实际操作上复杂度完全不可控。
堆里的对象动辄几百万、几千万个,对象之间的引用关系是一张巨型有向图。如果用“每个对象都要判断它是否被某个根引用”的思路,复杂度是 O(对象数 × 引用数),在几百 GB 的堆上简直就是灾难。而可达性分析从根出发做一次遍历,每个可达对象只被访问一次,整体复杂度接近 O(可达对象数),效率上完全不在一个量级。
这也是为什么可达性分析能成为工业级垃圾回收器的标配算法——它不是最花哨的方案,但它在大规模堆内存场景下足够快、足够稳定。
2.3 从根到对象:引用链的本质
引用链不是一个抽象概念,它实际就是对象内存布局中存储的引用字段。在 HotSpot 虚拟机中,对象头里有 Mark Word,对象体里就是实例字段。一个实例字段如果是引用类型,它存的就指向另一个对象地址。可达性分析的过程,本质上就是把这些地址串起来,形成一条条可达路径。
举个例子:一个线程栈上的局部变量指向了一个
