从GC Roots到可达性分析:Java内存溢出排查核心框架
每次有人问我Java内存溢出怎么排查我第一句话往往就是先把GC Roots和可达性分析搞懂。这两个词听上去像教科书概念但实际调优、分析堆转储时它就是所有问题的起点。对象到底是怎么被判死刑的不是靠还有人用这种模糊感觉而是靠一条精确的引用链路从根对象出发一步一步走走看走不到的就是垃圾。这篇文章就是拆开来讲这个过程的。如果你是Java开发者尤其是面对线上OOM、频繁Full GC或者内存泄漏问题的人这篇文章可以帮你建立一套完整的判断框架。哪怕你只是对JVM内部机制好奇也可以从里面理解标记存活这四字背后到底发生了多少次遍历、多少种妥协。1. 从引用计数到可达性为什么JVM放弃了数数这条路1.1 引用计数器的朴素思路每次赋值就加减在讲可达性分析之前有必要先聊聊早期垃圾回收方案里非常直观的一种——引用计数。思路很简单每个对象在内存里都挂一个计数器只要有地方引用了它计数器加1引用被覆盖或者置空计数器减1。计数器归零说明没人再用它了立刻回收。这个方案确实很好理解而且回收效率高不用等某个时刻统一扫描。Python的早期版本和一些脚本语言就走过这条路。但JVM主流的垃圾回收器比如HotSpot并没有采用它作为核心算法。为什么因为它在多线程、高并发环境下有这么几个硬伤。首先是计数器自身需要频繁更新和线程安全处理。每次引用变化都要修改计数器而且还要避免并发修改导致计数不准确这本身就要付出不小的性能代价。其次是循环引用问题相当致命后面我会专门展开。1.2 循环引用的死局谁也数不清谁看这段代码class Node { Node next; } public class Demo { public static void main(String[] args) { Node a new Node(); Node b new Node(); a.next b; b.next a; // 然后让 a、b 离开作用域 a null; b null; } }在引用计数方案里a和b互相引用计数器各自是1但已经没有任何外部变量能触达它们了。它们成了漂浮在内存里的一对死魂灵因为计数器不为0回收器永远不会碰它们。如果这种对象多了内存就被慢慢吃光。循环引用不是极端情况链表、树、图结构里太常见了。所以引用计数要么配合额外机制打破循环链要么干脆换一种思路。JVM选择换思路不只是为了修复这个Bug而是因为引用计数整个基础模型都不够稳。1.3 可达性分析的本质从对象转向路径可达性分析把内存布局看作一张有向图对象是节点引用是边GC Roots是入口节点集合。从这些入口出发顺着引用边一直往下走能走到的对象都被标记为可达也就是存活走不到的对象全部视为垃圾等待回收。这个思路的优势在于它不关心某个对象有多少人引用只关心有没有一条路径从根到达它。哪怕一个对象有1000个引用只要根路径断了照样回收。哪怕另一个对象只有1个引用但这条引用来自一个根对象它就能活下来。循环引用在这种模型下也变得无关紧要a和b互相连成小环但根出发的路径进不去整个环都是垃圾。这个视角转变非常关键后面理解三色标记、STW、调优全都是围绕从根找路径展开的。下面我们就看看GC Roots到底包括什么。2. GC Roots都有谁那些被JVM当作根的引用来源2.1 线程栈上的局部变量和参数最容易被忽视的根很多人以为GC Roots就是静态变量实际上最日常、最容易被忽视的是线程栈里的局部变量。每个线程在执行方法时虚拟机会往栈帧里的局部变量表存内容。只要这个变量是引用类型并且当前方法还没有退出这个引用就是一条根路径。想象一下这种场景一个方法里new了一个大对象然后调用了一个耗时很长的数据库查询期间GC发生这个大对象一定会被当作存活对象保护起来因为它还挂在栈帧上。等方法return了栈帧销毁这个引用才消失对象才可能被回收。所以局部变量表里的根是动态的生命周期短但数量巨大尤其是在高并发场景下几百个线程的栈帧合并起来构成了一大片根区域。另外栈帧里的操作数栈也可能持有引用比如正在执行的表达式里的中间对象一样算GC Roots。2.2 静态字段全局大动脉静态变量引用的对象也属于GC Roots并且生命周期极长通常伴随类卸载才消失。这是内存泄漏的高发地带。比如一个工具类里写了个private static ListString cache new ArrayList();只要这个类不被卸载list里面的所有对象一直可达。实际排查内存问题时我见过太多这样的情况业务代码把用户上传的文件内容、查询结果集、临时对象一股脑塞进静态Map美其名曰缓存结果GC Roots路径稳定得可怕堆根本没法收缩。静态字段本身存在方法区或者说元空间但它指向的堆对象就是根可达的。2.3 JNI引用跨语言的那些手Java Native Interface也就是JNI允许Java代码调用本地方法C/C在本地代码中也可以持有一个Java对象。本地方法里的引用分两种局部引用Local Reference和全局引用Global Reference。局部引用在本地方法返回后自动释放等价于线程栈里的局部变量生命周期短。全局引用需要手动释放它会一直保持对象可达直到调用DeleteGlobalRef。只要存在一个全局引用对象就不会被回收这是个藏在暗处的坑。比如某个底层组件用全局引用缓存了Java对象忘记释放内存就会一点点涨上去。2.4 运行时其他根类对象、常量池引用、synchronized锁等除了上面几类GC Roots还包括一些容易被忽略的成员类对象每个被加载的类Class对象本身可能通过静态字段或类加载器引用其他对象尤其在反射、动态代理场景下类对象会把元数据、注解、方法引用都撑起来。运行时常量池里的引用比如字符串常量、符号引用它们在HotSpot里也被当作根的一部分。synchronized修饰的锁对象正在被线程持有的锁那个Monitor关联的对象也在根集合里。系统类加载器的引用、JVM内部结构等这些偏底层一般分析时不需要全背出来。2.5 常见的非根别把方法区里所有东西都当成根方法区元空间里的类信息本身不属于GC Roots只有它们引用的对象才是。比如某个类的静态字段引用了堆中的实例根是这个静态字段而不是整个类。另一些常量池里的对象也只在被引用时才算根。日常用MAT或VisualVM分析堆转储时工具会展示GC Roots列表背后的选择逻辑正是JVM规范里定义过的那些类型。理解这个边界后你排查问题时就不会把为什么这个类还存活误解成整个类泄漏而是去找具体的字段引用。3. 可达性分析到底怎么走一遍从STW到三色标记3.1 根枚举为什么需要停顿所有业务线程可达性分析的第一步是找到GC Roots的集合这个过程叫根枚举。听起来简单但做起来很难JVM运行过程中线程栈在变静态引用在变寄存器里的引用也在变想在不停顿的情况下拿到一致的快照几乎不可能。想象一下GC线程正在从根出发遍历同时业务线程还在不断创建新对象、改写引用那结果就不可靠了。所以HotSpot的做法是在枚举根节点时让所有Java线程都停顿在一个安全点Safepoint上也即我们常说的Stop The World。这不是所有GC停顿的根源但根枚举阶段往往是其中一部分开销。安全点不是随便一个字节码指令都能进入的JVM会在方法调用、循环跳转等位置插入检查指令让线程运行到这里时才有机会挂起。安全区域Safe Region则是睡眠或阻塞中线程的处理方式它们不需要在安全点等待因为它们不会改变堆里引用关系。3.2 三色标记法白色、灰色、黑色的含义从根出发遍历对象图JVM不是简单贴个可达标签而是用白、灰、黑三色来标记状态这是理解并发GC的关键。白色尚未被访问过。分析开始前所有对象都是白色。分析结束后白色对象就是不可达垃圾。灰色本身被访问到了但引用的对象还没全部访问完。灰色是中间状态代表还有活没干完。黑色该对象和它引用的对象都已经被访问完它是可达的。遍历过程可以理解成从灰色根节点开始拿出一个灰色对象把它引用的白色对象染成灰色自己染成黑色反复直到灰色集合为空。这时候白色对象全是垃圾。这个涂色过程有个很大的好处可以给并发标记分阶段。GC线程在标记的同时业务线程还可以继续运行但必须处理新引用和旧引用的变化这就引出了下面两种策略。3.3 增量更新与原始快照并发标记的两个流派并发标记害怕一件事原本被标记为黑色的对象又被业务线程写入了一个指向白色对象的新引用。如果GC线程已经不再深入这个黑色对象了那么白色对象就会被漏标导致存活对象被回收这属于严重事故。针对漏标问题CMS采用增量更新Incremental Update当一个黑色对象被写入新的白色引用时把黑色对象重新标记为灰色让GC线程再去扫描它一次。G1和后来的回收器则偏爱原始快照SATB在赋值操作发生时记录下旧引用GC线程按快照继续标记确保在某个时间点之前已经存在的对象不会被漏掉。两种方案各有代价。增量更新可能产生重复扫描SATB则多了记录旧引用的负担。至于为什么G1选SATBCMS选增量更新背后还有关于浮动垃圾、回收暂停时间的权衡这里先不展开你只要知道可达性分析在并发环境下并不是一条直线完成就行。3.4 从可达到存活标记结果怎么影响后续阶段一次可达性分析把堆分成可达对象和不可达对象。但后面真正回收时不同垃圾回收器处理方式差别很大。以CMS为例标记完成后进入清除阶段把不可达对象的内存空间标记为空闲但不去移动存活对象因此会产生碎片。G1则是把堆划分成多个Region统计每个Region中存活对象占比优先回收垃圾最多的Region靠复制存活对象来压缩空间。也就是说可达性分析的结果不是终点而是决策依据。GC需要知道哪些Region值得回收存活对象有多少需要移动以及清空一个Region是否划算。如果整个堆都可达GC再猛也回收不出什么空间。4. 可达不等于永生finalize与引用级别的残酷真相4.1 两次标记finalize()给的一次复活机会很多人以为可达性分析做完后不可达的对象就直接被回收了。实际上对于重写了finalize()方法的对象JVM会多给一次机会。第一次标记后如果对象不可达JVM会检查它是否需要执行finalize()。如果需要JVM把这个对象放入一个低优先级队列——F-Queue由专门线程去执行它的finalize()。第二次小规模的标记在F-Queue之后如果对象在finalize()里重新让自己被某个GC Roots引用比如做成一个静态变量那它就逃脱了这次回收成功复活。但这是一个垃圾回收器非常不喜欢的路径。finalize()的执行时机不确定执行顺序也不保证而且还可能让对象复活导致明明已经清理过的对象又活了这种诡异状态。从Java 9开始finalize()已经被标记为废弃我强烈建议大家不要在新代码里使用它更不要依赖这个机制做资源释放。4.2 四种引用级别强、软、弱、虚可达性分析要配合引用类型一起理解。Java提供了四种引用类型它们的可达性定义不同回收优先级也不同。引用类型回收时机典型用途强引用永不回收除非没有GC Roots可达new出来的普通引用软引用内存不足时回收图片缓存、结果缓存弱引用每次GC发现即可回收WeakHashMap、ThreadLocalMap的key虚引用随时可能回收主要用于监听对象回收堆外内存回收、监控其中强引用就是普通对象引用只要存在根路径对象就活着。软引用和弱引用都是带有妥协的引用关系JVM在可达性分析时会把它们单列处理。举个例子一个对象只被弱引用指向那么在一次普通GC中即使它从根出发走不到它也会被回收如果它只被软引用指向JVM会等到堆内存不足时再回收。这就不只是可达不可达的二元判断而是引入了回收强度的优先级。4.3 常见误区可达又怎样只是说明这次不会回收经常有人问我通过GC Roots能找到某个对象为什么它还出现内存泄漏这里要区分一句话可达性分析只是当前时刻从GC Roots出发能走通。不是说它永远不会变成垃圾而是它是活着的。比如一个缓存Map里的value它的key在外面已经没有引用了但Map本身被一个静态变量引用着那么value还是根可达。这时候它不会回收但如果你认为它应该被回收那就是你对GC Roots的理解有偏差。真正的泄漏不是JVM的错而是使用者把一个本该只存活一会儿的对象塞进了一条不该长期存在的根路径里。4.4 被回收并不等于立刻从内存里消失其实即使一个对象被标记为不可达垃圾收集器也不是马上把内存还给操作系统。JVM只是把对应的内存区域标记为空闲供后续对象分配复用。你看到内存占用曲线没有下降不代表GC没干活有可能只是空闲内存被回收器握在手里。这同样解释了为什么分析内存问题时不要只看堆使用总量而要看GC日志里的GC after和GC before之间回收了多少。我见过不少新手盯着Used heap曲线发现GC后还很高以为泄漏了其实是存活对象本来就多或者回收后内存被JVM继续缓存了。5. 实战视角从GC Roots看内存泄漏与调优方向5.1 ThreadLocal为何会泄漏一条根路径的故事ThreadLocal是Java里一个很有代表性的话题刚好可以把GC Roots和可达性分析串起来。每个Thread内部有一个ThreadLocalMapkey是ThreadLocal对象value是你存进去的值。关键问题是ThreadLocalMap的Entry继承了一个弱引用key但value是强引用。画一下引用链线程存活的时候Thread对象本身是一个强引用从GC Roots出发能找到Thread然后找到ThreadLocalMap再找到Entry再找到value。因此只要线程活着比如线程池里的核心线程Entry里的value就一直可达根本没法回收哪怕你在业务代码里把ThreadLocal对象置空了也不行。这个问题的根源在于ThreadLocalMap的生命周期和线程绑定了而业务代码没有及时清理。正确处理方式是在finally里调用remove()或者如果用完就扔掉ThreadLocal对象靠弱引用key让Entry的key变null但value还是需要remove才能清掉。用GC Roots的视角看这就是一条典型的根路径泄漏。5.2 静态集合类缓存永远可达的巨无霸另外一个高频场景是静态集合类。比如一个全局静态的MapString, byte[]用来缓存文件内容只要Map存在里面所有value都根可达。如果这个Map没有容量上限或者往里面塞了太多大对象堆很快就被撑爆。这种问题排查起来很“经典”先dump堆然后用MAT打开用Dominator Tree看谁占内存多再去右键Path to GC Roots查看它为什么可达。通常跳出来的第一层就是java.lang.Class对某个业务类的引用再往下就是那个静态Map。看到这条路径后问题定位基本就结束了。5.3 用MAT分析最短根路径的思想具体操作上我一般这样做先获取堆转储文件。线上环境可以用jmap -dump:live,formatb,fileheap.hprof pid但如果是要保留全部对象不要加live。用MAT打开先跑内存泄漏检测报告或者直接用Histogram按内存大小列出对象。选中一个可疑大对象右键选择Merge Shortest Paths to GC Roots工具会列出从各个根到该对象的最短路径。排查这些路径中哪一条看起来是业务代码本来不该持有的。这里有个小技巧查看最短根路径时先排除软引用、弱引用和虚引用只看强引用路径。因为弱引用往往会被自动清理强引用路径才真正决定对象存活。MAT菜单里有exclude all phantom/weak/soft etc. references的选项直接选上立刻干净很多。5.4 日常编码中如何减少根引用泄漏理解了GC Roots之后对编码习惯的指导价值就明显了。以下总结几条我踩过坑后得出的规矩全局缓存一定要设上限优先用Caffeine这类带过期策略的缓存框架而不是裸用静态Map。长生命周期容器里不要持有短生命周期的数据尤其不要把请求级对象塞进全局集合。线程池里的线程是长期存活的ThreadLocal用完必须清理。局部变量尽量在小作用域内使用别图省事把引用抬到方法外甚至成员变量。JNI全局引用必须显式释放释放逻辑要放在finally块里。对于可以重建的数据优先考虑弱引用比如WeakHashMap但如果value还引用key要注意循环弱引用问题。这些建议看起来琐碎但每一个都能用GC Roots的分析路径解释。你不需要背下来只要在怀疑为什么对象回收不了时顺着根路径走一遍答案自然浮现。最后再分享一个个人体会GC Roots和可达性分析并不是只属于JVM专家的底层知识它就是你排查内存问题时的地图。我每次拿到一个heap dump第一件事就是寻找可疑对象到根的最短路径走了无数遍之后我发现真正有用的不是记住所有GC Roots类型而是养成一个习惯——任何一个大对象先问一句谁在引用它这条引用链多长时间如果这条链是长期存在的那就是泄漏的重灾区。以后有空我再写写具体GC日志怎么解读以及怎么用各种工具把可达性分析自动化为线上巡检的一部分。这次先到这里希望对被OOM折磨的朋友有帮助。