
第一次认真去搞懂可达性分析算法不是在看八股文而是在一次线上 Full GC 事故里。那个下午服务的 Young GC 频繁到每秒好几次老年代还在涨我用 jmap 把堆 dump 下来打开 MAT 一看满屏都是一个业务对象的实例成千上万。问题很清楚——这些对象被某个地方不当持有无法释放。但真正让我卡住的是凭什么判定它们“无法释放”JVM 到底是从哪几个点出发去找垃圾的这个问题的答案就是可达性分析算法。这篇内容就把这个算法揉碎了讲尽量不堆术语适合刚接触 JVM、或者被面试和调优里这些概念绊住的人。1. 线上 Full GC 之后我才真正开始研究“对象怎么才算活着”那个下午我记得很清楚。服务的告警群里不停弹消息Young GC 每隔一两秒就触发一次紧接着老年代也跟着涨Full GC 跑完老年代从 70% 掉到 60%过几分钟又涨回去。这种形态一看就是典型的“对象被谁长期抱着不放”的内存泄漏而不是单纯堆开小了。我当时的操作也标准jmap 把堆 dump 出来用 MAT 打开看 Histogram果然有一个业务对象的实例数量异常巨大。但接下来才是真正难的地方。MAT 确实能列出一大堆实例可你要是只会在 Histogram 里按 size 排序看到的结果就是“有一堆对象”这几个字没有任何排查价值。我当时盯着 Dominator Tree 发了半天呆才意识到自己对这些工具背后的算法理解得有多浅JVM 到底依据什么判断一个对象是否存活它是从哪几个点开始扫描的为什么有些对象明明在代码里已经没有变量指向它了却依然被判定为存活这些问题的答案全都指向同一个算法——可达性分析。这篇文章我想用最直白的方式把这个算法讲透不讲课不讲八股就讲它是怎么执行的、为什么这么设计、以及你把它搞明白之后再看 GC 日志、再看内存泄漏视角会完全不一样。适合刚接触 JVM 的人也适合那种“会用 jmap 和 MAT但说不出对象存活判定原理”的进阶开发者。读完之后你至少能回答这几个问题GC Roots 都有哪些三色标记到底在干嘛CMS 和 G1 在并发标记时的核心差异是什么以及为什么静态 Map 和线程池里的 ThreadLocal 动不动就拖垮老年代。2. 所谓“活着”其实是一条可达路径说了算先说结论JVM 判断一个对象能不能回收不是看“这对象还有没有人引用它”而是看“从 GC Roots 出发还能不能沿着引用关系走到它”。能走到就是活的走不到哪怕它在代码里刚被某个局部变量写过在当前遍历的视角里也是垃圾候选。2.1 先看为什么引用计数法干不了这活儿可能有人会问为什么不能用最简单的方式给每个对象记一个被引用的次数有变量指向它计数加一引用没了计数减一计数变成零就回收。这个思路在做 C/C 内存管理或者早期脚本语言时会用到缺点也很致命。首当其冲的是循环引用。两个对象 A 和 BA 内部有个字段指向 BB 内部有个字段指向 A其他任何地方都不再引用它们。按引用计数的逻辑A 的计数是 1B 的计数也是 1都大于零永远不被回收。可实际上对应用程序来说这俩对象已经是废料了没人能用得到它们。循环引用的场景又是真实存在的并不算偏门比如双向链表节点、父子互相持有的树节点。其次是开销。每执行一次a.field b这种赋值都要同步修改计数赋值越频繁、开销越大而且这种开销是分摊到所有业务代码里的不像 GC 阶段集中发生在暂停期。JVM 这种追求高吞吐的环境对这个额外成本很敏感。所以主流 JVM 不搞引用计数而是干脆走到另一条路上不管每个对象被引用了多少次只管“从根上出发还能不能走到”。这就绕开了引用计数所有的问题。2.2 把堆看成一张有向图事情瞬间清楚了把整个堆内存想象成一张有向图每个对象是图里的一个节点对象里持有其他对象的引用就是从这个节点指向另一个节点的一条有向边。那“判断对象是否存活”就变成了“从某些起始节点出发按有向边遍历能到达哪些节点”。这个视角转换特别重要。它意味着 JVM 清理对象时不需要对堆里的每一个对象做“有没有人引用我”这种被动检查而是主动从一个固定起点集出发沿着引用把整张图“走”一遍。走完以后没有被访问到的节点就是不可达对象。这有点像检查一栋楼的电路总闸位置固定你只需要顺着线路看哪些房间还通着电通着电的房间对应存活对象断了线的房间哪怕灯还挂在墙上也不会亮了。2.3 不可达对象并不会“立刻消失”顺便澄清一个常见误解被判定为不可达不代表这个对象马上就会被回收。可达性分析只是“标记”阶段标记完还要等待后续的清理动作。在 HotSpot 里一个不可达对象通常还要经过至少一次标记如果它重写了finalize()方法甚至可能在第一次标记时被“救活”重新建立引用链然后在下一轮 GC 里继续存活。finalize()这种机制本身很坑实际开发里千万别依赖它但理解这一点对看懂 GC 日志里的“经历过几次标记才回收”很有帮助。3. 起点到底从哪来GC Roots 清单上面说“从 GC Roots 出发”那 GC Roots 具体指什么这个不搞清楚你连 MAT 的 Path To GC Roots 功能都用不明白。我整理了一份比较常见的起点清单对应 HotSpot 的实现场景根类型具体来源排查时的解读虚拟机栈中的引用正在执行的方法里的局部变量、方法参数、临时对象引用线程正在跑着的代码在持有对象线程不死对象可能一直不会被回收静态字段引用类静态变量比如static Map、static List最常见的内存泄漏源头类和对象一起活到进程结束常量引用字符串常量池里的引用、final常量等生命周期极长常驻内存JNI 引用本地方法栈中 Native 代码引用的对象排查 JNI 层持有对象时看同步锁被synchronized锁住的对象锁没释放对象可能通过 monitor 被持有JVM 内部引用系统类加载器、常驻异常对象、Class 对象等基本不用管但要知道它们存在3.1 虚拟机栈最活跃的一类根我们平时写的 Java 方法执行的时候都会在虚拟机栈里生成一个栈帧里面存着局部变量表。局部变量表里如果有个引用类型指向堆里的对象那这个对象就是“当前正在使用的对象”必须作为 GC Roots。这个结论对排查特别重要。比如你用线程池跑任务任务里把一个很大的对象赋给了局部变量任务执行特别慢那么这个对象在这个线程执行完之前永远可作为根到达。线程池里的线程如果长期不销毁这条引用链就一直存在。用 MAT 看 Path To GC Roots最终经常会看到Thread相关的节点就是这个原因。3.2 静态变量隐蔽的内存泄漏大户静态变量的生命周期和类一致类又通常和进程一致所以一旦有人往static Map或static List里塞数据而不清理这些数据就等于住进了“永久居民区”永远不会被当成垃圾。这是生产环境最常见的泄漏类型没有之一。我处理过一个案例一个业务系统用静态 HashMap 做了个“临时缓存”key 是订单号value 是一整个订单上下文对象。代码逻辑里忘记删 key结果订单量一涨老年代直接被这个 Map 顶满。用可达性分析视角看就是有一条永远存在的根静态变量把大量对象拽住了。理解了 GC Roots 之后你在写这类代码的时候条件反射就会多问一句“这个静态持有会活多久”3.3 常量和 JNI 这类根平时不用太担心字符串常量池里的引用、final修饰的常量等生命周期长但通常数量有限。JNI 引用在纯 Java 应用里几乎碰不到真遇到了说明项目里已经引入了 native 层需要结合本地内存一起看。重点是心中有数可达性分析不是只从一个根出发而是从一整组根同时出发所以根的数量多少、深浅会直接影响标记阶段的耗时。4. 三色标记可达性分析在 JVM 里的真实执行过程理论知识足够“图论化”之后接下来要进入真正的执行细节。现代 JVM 的可达性分析并不是简单地做一次 DFS 或 BFS 就完事它用了一套叫“三色标记”的状态管理机制。这名字听起来玄说白了就是给遍历过程中的对象涂三个颜色。4.1 三种颜色到底是什么意思白色还没被扫描到的对象。遍历开始时所有对象都是白色。遍历结束后仍然白色的对象就是不可达对象。灰色这个对象已经被发现、已经被访问到了但是它内部引用的其他对象还没全部扫描完。灰色对象是“正在处理中”的状态。黑色这个对象和它直接引用的其他对象都已经扫描完了。黑色对象不需要再被访问。我用一个生活场景类比你在打扫一整层楼的房间每间房代表一个对象房间门口写着这间房还连着哪些其他房间。灰色就是你人正站在里面、走廊还没走完的那间房黑色是已经彻底打扫完、锁好门的房间白色是还没踏进去的房间。打扫过程结束的时候所有房间要么是黑色已扫过要么是白色从来没走到过。4.2 主循环其实就是一个广度优先遍历用伪代码描述三色标记主流程你几乎可以直接对应到广度优先搜索扫描开始时: 将所有对象标记为白色 将 GC Roots 能直接引用的所有对象放入灰色集合 // 伪代码中的 workQueue 就是灰色集合 while (workQueue 不为空): obj workQueue 取出一个灰色对象 for (fieldRef in obj 的所有引用字段): if (fieldRef 指向的对象是白色): 将这个对象标记为灰色 放入 workQueue 将 obj 标记为黑色 workQueue 为空: 所有仍为白色的对象判定为不可达其中每一步都要强调两个动作一是把当前对象字段里指向的白色对象“涂灰”二是把当前对象“涂黑”。为什么要用队列因为这样才能保证从根出发一层一层地把所有引用关系全部展开不会出现“漏掉某个对象的某个字段”这种情况。理论上 DFS 也可以但 BFS 对大规模对象图更直观也便于和并行标记线程协作所以实际实现基本都是类似队列的组织方式。4.3 手动推演一轮就明白了假设 GC Roots 只有对象 RR 引用了 B 和 CB 引用了 DC 没有任何引用另外堆里还躺着孤立对象 E没有任何引用指向它。初始R、B、C、D、E 全是白色。R 是根直接从根观察把 B 和 C 涂灰放入队列。取出 B。B 引用了 DD 从白变灰入队B 的所有字段扫描完B 变黑。取出 C。C 没有引用字段扫描完C 变黑。取出 D。D 没有引用字段D 变黑。队列为空。此时 R 是根B、C、D 都是黑色说明可达E 从头到尾是白色不可达标记为垃圾候选。整个推演过程非常简单但它是理解后面所有并发问题的基础。5. 并发标记最怕什么漏标的成因和解决思路如果你只学到三色标记这一步还不足以应对真实 JVM。真正让可达性分析变得复杂的地方在于标记过程不可能永远暂停用户线程。5.1 为什么不能全程 Stop The World标记要遍历整个存活对象图对一个大堆来说可能耗时几百毫秒甚至更久。如果这些时间全部算进停顿里业务应用的延迟会非常难看。所以 JVM 在设计并发 GC例如 CMS、G1 的并发标记阶段时允许标记线程和用户线程同时运行。可达性分析在这个阶段是并发的。并发带来的问题很直接用户线程在标记过程中还会创建新对象、删除旧引用、修改某对象的字段。这些操作可能导致“本来应该被标记为存活的对象没有被标记到”也就是漏标。漏标之后的结果就是活对象被 GC 误回收这是致命错误。5.2 用三色状态推演一次漏标我按教科书里最经典的例子推演给你看。当前对象 A 已经扫描完成变成了黑色A 原本引用着 B。此时用户线程执行了一句代码a.b c也就是把 A 的引用从 B 改成了 C。与此同时原本 B 到 C 的引用被删除了。而 C 在标记过程中还是白色还没有被任何灰色对象扫到。站在并发标记线程的角度A 已经是黑色扫描线程不会再看 A 的字段所以 A 改引用了 C 这件事标记线程感知不到而唯一能从“已扫描对象”走到 C 的那条路径 B-C 又断了。结果遍历结束时C 仍然是白色被当成垃圾。但实际上用户线程已经通过 A 持有 CC 无论如何都不该被回收。这种错误就是“把活对象标成死的”。三色标记本身在单线程全停顿下不会出错但一旦并发就必须对引用变化做额外兜底。5.3 增量更新和原始快照两种主流思路增量更新Incremental Update思路是“既然黑色对象被改了引用那你就别装黑了”。具体做法是在标记过程中记录哪些黑色对象的引用发生了新增然后把这些黑色对象重新置灰让它们再被扫描一遍。CMS 采用的就是这一套。原始快照SATBSnapshot At The Beginning思路是“把标记开始那一刻的引用关系拍个快照”。之后如果检测到某个引用被删除就记录下这个被删除的引用所指向的对象把它也当作存活对象保留下来。G1 采用的是这套。这两种策略都允许一些“该回收但暂时没回收”的对象浮动用“浮动垃圾”换取“绝不误伤活对象”。这是理解并发 GC 非常关键的一个取舍点。5.4 写屏障拦截引用改变的哨兵不管是增量更新还是原始快照都需要在引用发生变化的瞬间感知到这就引出写屏障。JVM 会在编译后的代码里对引用类型字段赋值相关的字节码插入一小段逻辑。每次执行a.field b这类操作时这段逻辑会被触发用来记录引用变化。它可以设计成“记录引用变更后的对象”对应增量更新也可以设计成“记录被删除引用的对象”对应原始快照。写屏障当然有开销只不过它比全程 STW 小得多属于典型的“用一点运行时成本换低停顿”的做法。看到这里你已经把 CMS 和 G1 并发标记的核心差异理解得差不多了。面试里问“CMS 和 G1 标记阶段的区别”本质就是在问增量更新和 SATB 的取舍。6. 算法搞懂之后排查内存问题就成了找引用链最后聊聊实战。前面这些内容看着偏理论但它们对你排查线上问题的帮助非常直接。最大的转变在于你不再只是看“哪个对象占内存大”而是开始问“这个对象是被谁通过哪条引用链拽住的”。6.1 MAT 的 Dominator Tree 就是可达性分析的产物MAT 打开 dump 文件后有一个非常常用的视图叫 Dominator Tree支配树。它的核心思想是如果从 GC Roots 出发到达某个对象 X 之前必须经过对象 Y那 Y 就是 X 的支配者。对象 Y 的子树里所有对象都可以被 Y“一票决定生死”。这个支配关系其实就是可达性分析在对象图上的一个后处理结果。所以你在 MAT 里看到某个大对象的支配子树特别大基本可以断定它是泄漏源或泄漏路径上的关键节点。在 Dominator Tree 上往下点右键选择 Path To GC Roots 或者 Merge Shortest Paths to GC Roots工具会自动帮你算出一条“从根到这个对象的引用链”中间经过哪个静态容器、哪个线程、哪个局部变量一目了然。这个功能背后的原理就是今天说的可达性分析只是工具帮你把那条引用链可视化出来了。6.2 用 GC 日志反向看标记阶段搞懂标记过程后再回来看 GC 日志能读出的信息也变多了。G1 的 GC 日志里标记阶段会拆成好几个子阶段比如根扫描Root Scanning、并发标记Concurrent Marking、重新标记Remark等。你会看到根扫描时长如果特别高往往意味着线程数量多、线程栈深或者类数量大并发标记时间长通常和存活对象总量、对象图的复杂度直接相关。这些子阶段的耗时本质上都是在执行图遍历过程中的不同环节。调优堆大小的时候也能反过来理解堆开得太大遍历距离变长标记时间自然上升堆开得太小GC 频繁对象频繁晋升。所以堆大小从来不是越大越好而是要在“标记耗时”和“GC 频率”之间做平衡。理解了可达性分析这些 GC 参数背后的代价模型就活了。6.3 我踩过的一个和可达性分析直接相关的坑最后分享一个我自己踩过的坑。有一阵子我负责的服务用线程池处理请求每个任务里用了 ThreadLocal 来存上下文但任务结束后没调用remove()。当时从 GC 日志看老年代在持续增长dump 下来一看上下文对象的实例数量特别大。在 MAT 里顺着 Path To GC Roots 一看引用链是这么走的上下文对象被 ThreadLocalMap 的 Entry 引用Entry 又被 Thread 引用Thread 是线程池里的存货线程线程本身是全局存活的根。所以哪怕业务代码里已经没人用那个上下文了它依然在这条引用链上坚挺地活着。如果停留在“对象没人引用就该回收”的错误认知里这个案例根本没法解释。但用可达性的视角看一切都很清晰线程是根根不死它引用链上的对象就都有存活理由。这也是为什么在线程池场景里使用 ThreadLocal 必须严格执行try-finally-remove。那次之后我再看内存泄漏基本形成了一个固定套路先用 GC 日志判断是晋升过快还是泄漏然后 dump打开 Histogram 按 retained size 排再进 Dominator Tree 看最大子树的根节点最后右键 Path To GC Roots一路点到业务代码的持有点。每一步背后本质上都是在做一次可达性分析的逆向查询。