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,对象体里就是实例字段。一个实例字段如果是引用类型,它存的就指向另一个对象地址。可达性分析的过程,本质上就是把这些地址串起来,形成一条条可达路径。
举个例子:一个线程栈上的局部变量指向了一个 User 对象,User 对象有一个 Order 对象的引用,Order 对象又引用了 OrderItem 对象——那 User、Order、OrderItem 都是可达的。如果某个 OrderItem 只被 Order 引用,而 Order 本身失联了,那 OrderItem 也一并失联。引用链是“一串”的概念,不是“一个”的概念——上游断了,下游全部陪葬。
3. GC Roots 到底有哪些?盘点所有“根”的类型
3.1 虚拟机栈中的局部变量与操作数栈
GC Roots 的第一大类,就是虚拟机栈中栈帧里的局部变量表和操作数栈。每个线程在运行时都会创建栈帧,栈帧里记录着方法参数、局部变量和中间计算结果。如果某个局部变量的类型是引用类型,那它指向的对象就是一个活对象,必须作为 GC Roots 的起点。
在实际实现中,JIT 编译后的代码会有一些优化——局部变量在一个作用域结束后,对应的槽位可能被复用,也可能被置空,目的是尽早释放引用,缩短对象生命周期。所以你在代码里写 obj = null,实际效果是让这个对象提前失去一个引用路径,有助于 GC 更早地回收它。但在现代 JVM 里,这种手写置空的意义已经很小,因为编译器自己会做类似优化,除非你在写非常底层的框架代码,否则没必要刻意为之。
3.2 静态变量与常量池引用
第二大类是方法区中类的静态属性引用的对象,以及常量池中的引用(比如字符串常量、类引用等)。静态变量的生命周期和类一致,类不卸载,静态变量指向的对象就不会被回收。
这里有一个典型的内存泄漏场景:你在一个工具类里写了一个 public static List cache = new ArrayList<>(),然后不停往里加数据,这些数据永远可达,GC 永远收不掉。静态集合是内存泄漏的重灾区,排查堆转储时经常能发现“根路径全部是 static 字段”的对象,看到这种基本就能定位问题了。
在 Java 8 之前,字符串常量池放在永久代里,常量池引用的对象也是 GC Roots 的一部分。Java 8 移除永久代后,字符串常量池移到了堆中,它的本质变成了堆内的一个对象集合,但常量池引用仍然参与可达性判定。实际排查中,字符串常量池也经常是内存占用的大头,大量动态生成字符串并且 intern 的话,会让常量池膨胀得厉害。
3.3 本地方法栈中的 JNI 引用
第三大类是本地方法栈中 JNI 引用的对象。Java 代码可以通过 JNI 调用 C/C++ 代码,本地代码里可能持有 Java 对象的引用——全局引用和局部引用。这些对象虽然在 Java 堆中没有对应的局部变量引用,但只要 JNI 还持有它们,GC 就必须把它们视为可达。
JNI 引用的处理对很多人来说是盲区,因为平时不写本地接口根本接触不到。但如果你在做一些底层基础设施、图像处理库、音频引擎之类的项目,JNI 引用的管理不当很容易造成“对象明明已经不需要了,但 C++ 那边还拿着引用不放”的情况,表现就是内存占用居高不下,Java 层怎么排查都查不出原因。
3.4 活跃线程与各类锁
还有一个容易被忽略的根:当前活跃的线程对象。JVM 规范里虽然没有明确把“线程对象”单列为 GC Roots,但在 HotSpot 的实际实现中,所有存活的 Java 线程对象是必须存在的——线程是运行时调度的基本单位,线程死了整个程序就崩了。因此 Thread 对象以及它关联的 ThreadLocal 值、线程上下文类加载器等,都算作可达对象。
说到 ThreadLocal,这是另一个经典泄漏点。ThreadLocal 的 key 是弱引用,但 value 是强引用。线程存活期间,ThreadLocalMap 里那些 key 已被回收、value 还在的条目永远无法被 GC 命中,直到线程结束。线程池场景下线程都是长期复用的,所以用线程池 + ThreadLocal 就很容易出现 value 泄漏。这是可达性分析机制下的一个“合理但反直觉”的结果——对象明明没用了,但因为链条上某个根还挂着你,你就是死不掉。
3.5 被 synchronized 持有的对象
最后一种:被同步锁(synchronized)持有的对象。一个对象如果正处于被某个线程 lock 的状态,那它也是 GC Roots 的一部分。因为锁的核心语义是“互斥”,如果持有锁的对象被回收了,那锁的语义就崩了。所以 JVM 在处理可达性分析时,必须把这些加锁对象标记为可达。
在实际 GC 过程中,这类对象的数量通常不多,但排查死锁或锁相关的堆转储时,会看到锁对象占据着引用链的关键位置。理解这一点,有助于看懂在线程 dump 中“locked <0x00000007ff6f3a10>”这类信息背后的含义——那一串地址就是一个被当作 GC Roots 的锁对象。
4. 从根到活对象:三色标记算法与读写屏障
4.1 为什么需要三色标记
可达性分析听起来简单,但真正实现起来有一个巨大的难点:GC 线程在遍历对象图的时候,业务线程还在疯狂创建新对象、修改已有引用,如果不做任何处理,可能出现“刚标记完的对象又被改了引用”的情况,导致标记结果不准确。
最暴力的方案是全程 Stop The World,也就是暂停所有业务线程,GC 线程慢慢标记,标完再恢复。这种做法在堆很小时没问题,但堆稍微大一点,几十 GB 堆的标记过程可能耗时数秒,对在线业务就是灾难。CMS 和 G1 都希望“尽可能少地暂停”,那就要解决标记过程中业务线程并发修改引用的问题。
这时候三色标记算法就出场了。它把对象分成三种颜色:
- 白色:尚未被访问到的对象。所有对象初始状态都是白色,标记结束时仍为白色的对象就可以回收了。
- 灰色:当前对象自身已被访问到,但它引用的对象还没全部访问完。灰色是“正在处理中”的中间状态。
- 黑色:对象自身和它引用的对象都已被访问完。黑色对象是安全的,GC 不会再次访问它。
标记过程就是从 GC Roots 出发,把根对象标成灰色,然后不断从灰色对象中取出一个,把它引用的所有白色对象标灰,再把自己标黑。循环往复,直到队列里没有灰色对象为止。最终,白色对象就是不可达对象,黑色对象是存活对象,灰色对象不应该存在——因为队列空了。
4.2 并发标记的致命问题:漏标与错标
三色标记在纯串行环境下完全没问题,但一旦 GC 线程和业务线程并发,就会产生两种严重的错误:
漏标:本应该被标记为存活的对象没被标上,结果被 GC 误回收。这是致命错误,因为 Java 程序的对象存活状态是确定的,回收一个还活着的对象等于程序崩溃。
漏标发生的条件有两个缺一不可:第一,某个黑色对象(已经标记完成)新增了一个指向白色对象的引用;第二,原本持有该白色对象引用的灰色对象被删除了到它的引用。
错标:本应该被回收的对象被标记为存活。这个错误影响较小,顶多是漏回收,造成一点内存浪费,下一轮 GC 还能纠正它,不会导致程序崩溃。
4.3 解决方案:增量更新与原始快照
针对漏标问题,主流 JVM 有两种不同的解决思路:
CMS 用的是增量更新:当检测到一个黑色对象新增了指向白色对象的引用时,就把这个黑色对象重新标灰,等待后续重新扫描。这样即使原本指向白色对象的灰色引用被删了,黑色对象也能通过“重新变灰”把这条新引用链重新扫描出来。可以把增量更新理解为“记录变化”——既然你改了,我就看看你改了什么,把改动的部分重新处理一遍。
G1 用的是原始快照:在并发标记开始时,所有存活对象的状态被打了一个快照。之后即使某个引用被删除了,只要在快照里这个引用还是存在的,GC 仍然会把这个对象当作存活对象处理。原始快照的代价是会产生一些“浮动垃圾”——明明已经不可达了,但这一轮 GC 仍然认为它活着,下一轮再回收。但它的好处是实现更简单,不用频繁地把黑色对象回退成灰色。
这两种机制都需要 JVM 在运行时拦截引用赋值操作,这就是读写屏障的用武之地。所谓写屏障,就是在执行 a.field = b 这样的赋值操作时,额外插入一段逻辑——如果是增量更新,就检查 a 是否为黑色,如果 b 是白色就把 a 重新标灰;如果是原始快照,就在赋值发生前把 b 记录下来,表示“快照里这个引用是存在的”。
在 HotSpot 的 C2 JIT 编译器中,这些屏障逻辑会被编译成机器码插入到对象赋值的地方。所以你在 Java 代码层面完全感知不到这些操作,但每一次引用赋值背后,可能在偷偷执行着 GC 的标记逻辑。这也是为什么“GC Roots 枚举”和“并发标记阶段”要求应用线程必须到达安全点才能暂停——因为只有到了安全点,栈上的对象引用状态才是最稳定的,JVM 才能生成准确的 OopMap。
4.4 从 OopMap 到安全点
上面反复提到 OopMap,它是 HotSpot 用来记录“当前栈帧里哪些位置是引用类型”的数据结构。JIT 编译时,编译器会为每个方法的特定位置生成 OopMap,告诉 GC 此时栈上哪些槽位存放的是对象引用。GC 在枚举根节点时,只需要读取当前线程栈顶帧对应的 OopMap,就能快速找到所有局部变量引用,不需要逐个栈帧去猜哪个值是引用、哪个值是整数。这一步是可达性分析能够高效执行的关键基础结构。
安全点则是 GC 要暂停线程时必须等待的位置。程序运行到安全点时,OopMap 才处于最新可用状态,JVM 才能安全地获取线程的寄存器状态和栈状态。常见的安全点位置包括方法调用指令、循环跳转指令、异常抛出处等。如果一段代码长时间不进入安全点(比如大数组循环没调用方法),GC 就一直等它,表现为线程在 safepoint 处长时间停顿——这在排查 GC 停顿异常的现场时是很常见的原因之一。
5. 从根到完整流程:CMS 与 G1 是怎么做可达性分析的
5.1 CMS 的四步流程
CMS(Concurrent Mark Sweep)是许多 Java 开发者最早接触的并发垃圾回收器。它的核心目标是减少停顿时间,因此把可达性分析拆成了四个阶段:
初始标记:这一步要 Stop The World,但时间很短。它只需要从 GC Roots 出发,标记直接可达的对象。注意只是“直接可达”,也就是第一层引用,不需要递归遍历整张图。实际停顿时间一般在几十毫秒以内,和堆大小关系不大,主要受 GC Roots 数量影响。
并发标记:这一步是全程并发的,业务线程继续执行,GC 线程从初始标记得到的直接可达对象出发,递归遍历整张引用图,把能走到的对象全部标灰、标黑。这是整个可达性分析中耗时最长的阶段,并发执行的目的就是把这部分开销和业务时间重叠。
重新标记:这是第二次 Stop The World,但时间比初始标记长一些,比并发标记短很多。它的用途是修正并发标记期间业务线程修改引用导致的漏标。CMS 基于增量更新机制,把被改动的对象重新扫描一遍。这个阶段的耗时和并发期间“引用被修改的次数”有关,而不是和堆大小直接相关。
并发清除:重新标记完成后,白色对象就是“确定已死”的对象。并发清除阶段就可以安全地回收它们。清除阶段同样是并发的,业务线程不受影响。
从这里可以看出,CMS 的整个可达性分析过程被精心拆解成“短的 STW + 长的并发 + 短的 STW + 并发清理”的模式,以有限的几次短暂停顿换取了整体上极低的 GC 延迟。
5.2 G1 的 Region 与回收集
G1 在设计上与 CMS 有本质区别。它把堆划分为多个大小相等的 Region(通常 1MB~64MB),每个 Region 可以是 Eden、Survivor、Old 中的任何一种。G1 的可达性分析不是对整个堆做统一标记,而是基于 Region 维度的回收集(CSet)管理。
G1 的标记周期也有类似 CMS 的多阶段流程:初始标记、根区域扫描、并发标记、重新标记、清理。根区域扫描是 G1 特有的——因为 G1 把年轻代和老年代都分在多个 Region 里,年轻代 GC 之后,存活对象可能被复制到其他 Region,这些新区域的根引用需要被扫描。
在并发标记过程中,G1 会记录每个 Region 中存活对象的比例。标记结束后,G1 根据回收收益来挑选回收集——优先回收那些“垃圾比例高、回收成本低”的 Region。G1 用的原始快照方案让它容忍了一部分浮动垃圾,但这些浮动垃圾只存在于被标记的 Region 中,下一轮年轻代 GC 或后续标记周期会处理它们。
5.3 ZGC 与染色指针
如果你的 JDK 版本是 15 以上,ZGC 也是一个值得了解的选项。ZGC 的可达性分析思路和 CMS/G1 都不一样,它引入了染色指针技术——把对象存活状态直接编码在 64 位指针的高位标记位里。ZGC 在标记阶段遍历对象图时,会通过设置指针上的标记位来记录对象状态,整个过程几乎不需要读写屏障,停顿时间可以控制在几毫秒以内,而且不受堆大小影响。
ZGC 的实现细节非常复杂,但对本文主题来说,只需要理解一点:可达性分析的目标没有变,变的只是标记的方式——从“额外维护一块状态数据”变成了“在指针本身做数据编码”。 这算是可达性分析工程实现的一个极简方向。
6. 常见问题与排查技巧实录
6.1 频繁 Full GC 但堆内存并未被打满
这是个很经典的现象:GC 日志显示 Full GC 频繁发生,但堆使用率一直没到阈值,甚至每次 GC 之后堆占用率很高,但根本没满。
排查思路是:先看是不是元空间被占满了。元空间不足会触发 Full GC,但可达性分析的对象关系并不复杂。另一个可能是 System.gc() 被代码显式调用了,比如某些框架在特定条件下会主动触发一次全局 GC。在 JDK 17 之后,System.gc() 在默认 GC(G1)下不会真正触发 Full GC,而只是触发一次年轻代 GC 加一次并发周期,但在老版本(JDK 8 + CMS)下调用 System.gc() 会实打实地触发 Full GC 并 STW。
我实际碰到过的一个场景是:某台服务器每天凌晨定时任务会触发一个批量计算,计算过程中会调用 System.gc() 做一些“内存整理”,结果每到凌晨 GC 停顿就飙升到数秒,直接导致上游超时报警。最终方案是去掉显式 GC 调用,并对批量任务做了内存复用优化。
6.2 内存泄漏但无法定位对象为什么没被回收
这是最让人头疼的情况之一。堆转储文件里明明看到某个业务对象占了几百 MB,但它的引用链完整无缺,每个引用都合理,就是没被回收。这个时候要重点排查是不是静态集合 + 弱引用失效的组合。
我遇到过一个具体案例:系统里有个全局缓存,为了不影响 GC,使用了 WeakHashMap 作为底层实现,但 key 用了强引用对象。理论上 entry 应该在内存紧张时被回收,但实际上 key 一直被某条链路强引用,导致 WeakHashMap 里的 value 永远跟随 key 存活。从可达性分析的视角看,value 并没有断链——它的根路径经过了那个被强引用的 key,所以它活得理所当然。最终定位到根因,改用显式的 LRU 缓存设计,问题才解决。
排查这类问题时,一个实用的技巧是利用 MAT(Memory Analyzer Tool)的 Path to GC Roots 功能。它会把每个可疑对象到 GC Roots 的完整引用路径列出来,你只需要检查路径上的每个节点是否“真的有必要引用它”。绝大多数内存泄漏的根因,都在那几条路径上。
6.3 并发标记期间业务线程停顿较长
如果你在 GC 日志里看到类似 GC (G1) Concurrent Mark 阶段的耗时异常偏长,但实际标记的对象数量并不大,那就要考虑分配速率过高的问题。并发标记期间业务线程还在持续创建新对象,G1 需要跟踪这些新对象的存活状态,如果分配速率极高,标记线程会持续处于高负载状态,导致并发标记阶段迟迟无法结束。
解决的思路不是调标记线程数——提高标记线程数在某些场景下有效,但根本问题在于业务代码的对象分配太频繁。优先查一下是否有 hidden 的大数组分配、在热路径上创建了大量短生命周期对象、或者在循环里重复创建复杂对象。用 JFR 或者 JMC 录制一段分配采样,很快就能定位到热点分配点。
6.4 安全点长时间等待导致 STW 时间被拉长
最后聊一个容易被忽略的问题:可达性分析中 STW 的停顿时间,有时候不是花在 GC 本身,而是花在“等待所有业务线程进入安全点”上。GC 发起时,JVM 要向所有线程发送安全点请求,线程要运行到最近的安全点才能挂起。如果有一个线程长时间在密集计算而没触发安全点,所有线程都会等它。
这类问题的典型表现是 GC 日志中的 vmop 时间正常,但 safepoint 时间异常长。排查手段是录制 JFR 的事件,找出那个长时间处于 running 状态且未进入安全点的线程,再定位它执行的方法。大多数情况下是某个纯计算循环没有方法调用,比如图像处理、加密计算、格式化超大型 JSON 等。
7. 从可达性分析看垃圾回收器的设计哲学
7.1 设计取舍:吞吐量与延迟的平衡
可达性分析在整个垃圾回收体系里只是“判定生死”的前置步骤,但它直接影响了 GC 器的所有后续行为。CMS 追求低延迟,所以把标记过程拆碎,用增量更新来补偿并发修改;G1 追求可预测的停顿,用 Region 和原始快照换来更好的吞吐和回收收益;ZGC 追求极低延迟,用染色指针把标记成本进一步压低。
这些设计本质上都是在回答同一个问题:可达性分析这一件“从根出发遍历整张图”的事,能不能在业务不停的前提下做完? 不同时代的 JVM 给了不同的答案,而这些答案的演化过程,背后就是整个 Java 服务端技术从“偶尔停几秒没关系”到“每毫秒停顿都可能损失真金白银”的演进史。
7.2 对普通开发者的实际意义
了解可达性分析,不是为了写一个垃圾回收器,而是为了在排查问题时能快速定位方向。比如你看到堆转储里有个对象“没被回收”,你不需要问“这个对象是不是垃圾”,而应该问“为什么它仍然和某个 GC Roots 保持着引用关系”。这个思维方式的转变,比记住任何 API 都重要。
我再分享一个个人习惯:排查 GC 相关问题时,我固定会按这个顺序操作——先看 GC 日志中的各阶段耗时分布,再抓一份堆转储看存活对象的对象数和字节数排名,然后对可疑对象执行 Path to GC Roots 分析,最后结合业务代码确认引用路径上哪些引用是“多余的”。这套流程让我在最快的速度内定位了绝大多数内存问题,你也可以直接沿用。
可达性分析本身并不难,难的是在实际复杂的业务场景里,把“对象为什么还活着”这个问题问到点子上。希望这篇文章能帮你形成自己的排查思维,下次再看到 GC 卡顿或内存泄漏,不会再一头雾水。
