ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

JVM垃圾回收核心:GC Root、可达性分析与三色标记法详解

JVM垃圾回收核心:GC Root、可达性分析与三色标记法详解 1. 先搞懂GC RootJVM判定垃圾的“起点”1.1 可达性分析不是无根之水很多Java开发者背过“JVM使用可达性分析算法判定对象是否存活”但真被问到“什么是可达性分析”又只会说“从GC Root出发找能找到就是活的找不到就是垃圾”。这话没错但太笼统。我面试过不少候选人能把可达性分析讲到“根”这个层面上的十个人里不超过两个。先打个比方。你收拾房间想判断一件旧衣服该不该扔标准是什么不是你记不记得它而是你近期还会不会穿它。如果这件衣服压箱底三年没碰过但它就在衣柜角落里你总不能说“我把它忘了所以它是垃圾”它依然占着空间。GC也是一样一个对象有没有被“用到”要看有没有一条从“活着的起点”出发、能到达它的引用链。这个起点就是GC Root。JVM里不叫“活着的起点”但思路完全一致。一个对象只要从某个GC Root出发能沿着引用链摸到它它就是“可达”的垃圾回收器就不会动它一旦从所有GC Root出发都摸不到它了它就进入了回收候选名单。这比引用计数法高明在哪高明在它不依赖对象之间的“相互记得”而是从全局视角看“谁还跟外界连着”。这里有个关键点容易被忽略可达性分析处理的是“引用”不是“对象本身”。JVM在标记阶段遍历的是引用关系图边是引用顶点是对象。所以GC Root必须是“能直接或间接持有关键对象引用”的锚点而不是随便一个对象。1.2 哪些对象可以充当GC RootJVM规范没有把GC Root列成一个固定清单但HotSpot虚拟机里常见的有这么几类GC Root类型说明典型例子栈帧中的本地变量正在执行的方法里局部变量表引用的对象main方法里的List对象栈帧中的操作数栈运算过程中压栈的引用方法调用时的临时引用静态变量类加载后存在于方法区元空间的静态字段public static Config CONFIG常量池引用运行时常量池中的字符串、类引用等String.intern()的对象JNI引用本地方法栈中Native方法引用的对象Java调用C/C时传入的全局引用锁对象被synchronized持有锁的对象同步代码块监视器对象活跃线程Thread对象本身以及线程中活跃的栈帧运行中的Thread实例我得强调一下静态变量这点。很多人以为静态变量一旦赋值对象就永远不被回收事实不是绝对的。如果持有静态引用的类被卸载了比如自定义类加载器失效整个类连同它静态字段引用的对象都可能被回收。不过类卸载场景很少见日常说的“静态引用导致内存泄漏”更多是对象本身不再需要但类还在活着。还有一类容易漏掉的GC Root是“系统类”里的引用。比如ClassLoader对象、Class对象本身它们也会被当作根。如果你用jmap -dump导出的堆里看到很多Class实例占内存别惊讶那是类加载器链路上的对象。这里顺便纠正一个常见误区GC Root不一定是“根对象”它更像是“根位置”。一个对象既可以是GC Root也可以是普通可达对象。比如在线程栈上有一个User对象引用它自己就是一个root如果另一个Order对象引用了同一个User那User在这条链路上既是root也是被引用者。说清楚这一点后面理解三色标记法的“根集合扫描”就不会懵。2. 循环引用为什么JVM不怕但程序却会崩2.1 引用计数法的死结循环引用这个词在面试题里通常和“引用计数”绑在一起。你如果用过Python、Objective-C这类带引用计数的语言大概率被循环引用坑过。引用计数的思路特别朴素每个对象都有一个计数器被引用一次就加1引用失效就减1计数器归零就说明没人用了立刻回收。听起来很合理但有个致命缺陷——如果两个对象互相引用而外部引用全部断开它们的计数器永远不为零。举个例子class A { B b; } class B { A a; } A a new A(); B b new B(); a.b b; b.a a; // 然后外部不再使用 a 和 b a null; b null;此时A和B的对象体还在互相指着对方。A的计数器至少为1被B引用B的计数器至少为1被A引用引用计数法就认为它们都活着于是这对“僵尸对象”永远占着内存。这个场景放到引用计数语言里必须靠弱引用或者手动断环才能解除。JVM的HotSpot并没有采用纯引用计数而是用可达性分析。前面说了可达性分析从GC Root出发只要外部引用都断了A和B之间哪怕缠成一团从GC Root也找不到它们。这意味着什么意味着循环引用在JVM里不会被误判为存活对象它们会正常进入回收流程。2.2 可达性分析如何解开环我画过很多次这个场景的引用图其实解开循环引用不需要什么复杂算法。GC从一组根集合开始沿着引用边做遍历记录所有访问过的对象。遍历时用的是类似图搜索的逻辑已经访问过的对象会被打上标记不会因为环的存在而无限循环。关键是遍历的起点是GC Root不是“所有对象”。只要外部入口断了环内对象就像孤岛一样没有任何一条边能从根到达它。这个过程里有个性能点如果堆里对象特别多遍历要花费不少时间。所以JVM用了OopMap、Remembered Set这类结构来优化“哪些位置有引用”的查找速度但原理还是图遍历。我理解很多新手第一次接触“可达性分析不怕循环引用”时会觉得有点反直觉A引用BB引用A它们两个明明“还活着”对它们在你脑子里是活的但在GC眼里活着的定义是“能从根摸到”。一个没有任何外部入口的对象就算它内部引用满天飞对程序来说也毫无意义了。不过这里要泼盆冷水。JVM不怕循环引用不代表你可以随便写循环引用。前面那个例子如果A和B外部引用断开后它们确实会被回收。但如果外部引用没断只是你不再用其中一个对象了那另一个对象就能通过外部引用把整个环拖住。比如某个全局缓存还在引用A那么A和B就都还可达不会回收。这跟“循环引用本身”没关系是外部强引用的问题。2.3 代码实测循环引用对象被正常回收我写了一段验证代码用弱引用观察对象是否被回收public class CycleTest { static class Node { Node next; } public static void main(String[] args) throws InterruptedException { Node a new Node(); Node b new Node(); a.next b; b.next a; WeakReferenceNode refA new WeakReference(a); WeakReferenceNode refB new WeakReference(b); // 断开外部强引用 a null; b null; System.gc(); Thread.sleep(100); System.out.println(refA.get() null ? (refA.get() null)); System.out.println(refB.get() null ? (refB.get() null)); } }在我本机OpenJDK 17上输出是refA.get() null ? truerefB.get() null ? true。说明两个互相引用的对象都被回收了。这个实验很简单但它直观地证明了可达性分析不关心循环引用。真正需要警惕的“循环引用导致内存问题”的场景往往是使用引用计数机制的框架比如某些JVM之外的内存管理库。自己实现的缓存里对象之间互相持有强引用且缓存自身生命周期过长。事件监听器注册后没有注销监听器又持有主题对象的引用形成一条长链。这些问题的根子不是“循环”而是“引用链意外拉长”。所以面试时如果有人问你“循环引用会不会导致内存泄漏”标准回答是JVM的GC算法不会因为循环引用而漏回收但应用层如果让不可达对象持续被根引用那才会泄漏。3. 三色标记法并发垃圾回收的“交通灯”3.1 从两色到三色为什么黑色白色不够如果GC在单线程下做标记直接给每个对象打个“已访问/未访问”的二进制标记就够了。但现代JVM里GC大部分时间是和业务线程并发执行的。业务线程一边改引用GC线程一边遍历对象图问题就来了如果一个对象刚被标记完业务线程又往它身上塞了一个新引用GC该不该追过去看看这就需要一个比“是/否”更精细的状态模型。三色标记法把对象分成三种颜色白色还没被扫描到或者确定不可达。遍历结束时白色对象就是垃圾。灰色对象本身已被扫描至少被访问过但它的引用字段还没有全部扫描完。黑色对象和它的所有引用字段都已扫描完确定可达。黑色对象基本不会再看它了因为它的引用关系已经整理完。灰色对象是进程中的“工作集合”需要继续扫描它的引用。白色对象则是潜在垃圾。这个“颜色转换”过程跟多线程并发编程里的状态机一样核心是在并发的场景下保证“不重不漏”。3.2 三色标记的完整流程我拆解一下标准的三色标记流程步骤是这样的初始标记从GC Root出发直接把根引用的对象标成灰色。这个阶段通常要Stop The WorldSTW但时间很短因为只处理根集合附近的少量对象。并发标记GC线程从灰色对象出发扫描它的引用字段把扫描到的白色对象标记成灰色当前对象的所有引用字段都扫完后把当前对象从灰色变成黑色。业务线程这时还在跑所以这个阶段并发执行。重新标记并发标记期间业务线程可能改了引用导致一些本该标记的对象没被标记。重新标记会STW用来修正这些变化。这个阶段通常很短。清理把仍然是白色的对象回收掉。关键在第2和第3步。如果并发标记期间完全不登记业务线程的改动那么很可能出现两种情况一种是“漏标”——一个本来从根能到达的对象因为并发修改导致GC没扫到它最后被当成垃圾回收掉程序直接崩溃另一种是“错标”——一个已经不可达的引用链因为并发修改被重新挂到某个活对象上导致垃圾没被清掉内存泄漏。这两个问题里漏标是致命的错标顶多多占点内存。所以GC设计的所有“屏障”手段本质上都在防漏标。3.3 并发标记两个致命问题漏标与错标我举个漏标的具体场景。假设有三个对象A、B、C。GC线程正在标记过程里GC线程已经把A从灰色变成黑色A的引用已经扫描完。但B还没有被扫描还挂着对C的引用。业务线程突然执行了a.field null把A到B的引用断掉。接着又执行c.field b把B挂到C下面。等等这个例子里B本来是A的孩子现在断了C又引用了B。如果C本身是可达的那B仍然可达不会漏标。我需要换个经典例子。漏标的标准场景是黑色对象E刚被扫完持有对白色对象S的引用。业务线程断掉E对S的引用。同时某个灰色对象G持有对S的引用业务线程把G对S的引用也断掉或者改成引用别的对象。然后S再也没有其他引用链但S仍然是白色。如果GC不管这个变化S在标记结束时会被当垃圾回收但业务线程可能还有别的方式能间接访问到S如果S真的没有可达路径了那回收也算对。漏标真正的可怕之处在于“本该在后续被标记但因为并发修改导致标记线程不再访问它”。我再换一个经典例子对象A灰色或黑色引用对象B白色。业务线程执行A.field null断开A到B。同时另一个已扫描完的黑色对象C之前没有引用B业务线程执行C.field B让C引用B。由于C已经扫描完GC不会再理C的引用变化。B从白色变成被一个黑色对象引用但标记线程看不到这条新引用于是B仍然保持白色最后被错误回收。看到问题了漏标的充分条件是“黑色对象引用了白色对象同时从灰色路径上断开了对白色对象的引用”。所以只要阻止“黑色对象在标记期间重新指向白色对象”漏标就不会发生。这就是写屏障存在的意义。解决漏标有两大流派增量更新Incremental Update记录“黑色对象新增了对白色对象的引用”把黑色对象重新变灰强制重新扫描。CMS用的这种思路。原始快照SATBSnapshot At The Beginning记录“灰色/白色对象被断开引用之前的状态”保证所有在并发标记开始时仍存在的对象都被标记即使后来引用断了。G1用的这种思路。我的理解是增量更新关注“新增了什么”SATB关注“删除前快照里有什么”。两者都依赖写屏障在引用赋值的时候做一些记录。3.4 增量更新与原始快照CMS和G1的选择CMS全称Concurrent Mark Sweep它的并发标记阶段采用增量更新。当业务线程给某个字段赋值时如果新值是一个白色对象且字段所属对象是黑色写屏障会把这个黑色对象重新标记为灰色加入标记栈。这样并发标记阶段结束后重新标记阶段还会再扫一遍这些被重新标灰的对象避免漏标。代价是可能产生“浮动垃圾”——一些在标记过程中变成垃圾的对象会因为增量更新被多标记一轮这轮收不掉留到下次GC再收。G1选择SATB。具体做法是每当一个引用被覆盖前写屏障会把“引用将要指向的旧对象”记录下来。比如A.field null先记录下“A原来指向B”这件事后续GC会认为B仍然是可达的即使它已经被断开。这种做法的好处是并发标记期间引用变化不会导致对象被漏标因为所有在快照里出现的对象都会继续被标记。坏处同样会产生浮动垃圾而且如果快照里引用大量对象标记栈会膨胀。我用一张表对比下策略记录时机核心目标代表GC代价增量更新赋值之后防止黑色对象新增白色引用CMS可能重复扫描浮动垃圾更多SATB赋值之前保留旧快照防止白色对象被漏标G1快照可能保留本应死掉的对象很多人问“为什么现在G1是默认CMS几乎被淘汰”。一个原因是CMS碎片化严重另一个就是增量更新在并发标记结束后还需要重新标记停顿时间不稳定。G1用SATB搭配分区回收能更好地控制停顿预测。顺带说一下跨代引用。新生代C区回收时老年代对象也可能引用新生代对象。如果GC只扫新生代老年代里的引用算不算root这时候要用到Remembered Set记录老年代哪些分区被新生代引用。G1里叫RSetCMS里叫Card Table。面试问到三色标记时经常连环问“那跨代引用怎么办”把这块补上会显得你基本功很扎实。4. 实战GC日志分析、内存泄漏诊断与面试题串讲4.1 用jstat和GC日志判断GC压力光背概念没有用真到了线上OOM还是得靠工具。我最常用的命令是两个jstat和jmap。先用jstat -gcutil pid 1000 10看每秒GC情况jstat -gcutil 12345 1000 10输出里的P0、P1在JDK 9之后变成了EEden、S0/S1、O老年代、M元空间还有FGC和FGCT标记Full GC次数和耗时。如果看到Old区不断上涨FGC越来越频繁基本可以断定老年代在持续累积大对象或者存在泄漏。接着打开GC日志。如果你用的是JDK 8常见参数-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/path/to/gc.logJDK 11之后统一为-Xlog:gc*:file/path/to/gc.log:time,uptime,level拿到GC日志后我一般看一眼两点Young GC耗时和晋升情况。如果晋升对象过大可能导致老年代空间快速增长。Full GC之后的堆占用。Full GC后老年代占用依然很高那就大概率有泄漏或大对象没释放。实操中很多人会忽略“GC日志里JVM真实使用的是哪个垃圾回收器”。JDK 8默认Parallel GCJDK 9默认G1。如果你拿JDK 17的日志套JDK 8的经验格式完全不一样容易看错。先确认PrintGCDetails输出的第一行是什么GC器再分析。4.2 MAT排查内存泄漏的步骤最有效的泄漏定位方法还是堆转储。先触发Dumpjmap -dump:live,formatb,fileheap.bin 12345或者用jcmd 12345 GC.heap_dump heap.bin。拿到华为Params? 不对这里只说Eclipse MAT。用MAT打开heap.bin后看“Leak Suspects”报告。它会把占用最大的几个对象链列出来。重点看“Shortest Paths To GC Roots”——从GC Root到问题对象的最短引用链。如果链条里有一个被你创建的缓存、静态集合或Spring容器那基本就是泄漏源头。我遇到过一个典型案例一个定时任务每次往静态Map里塞数据key是用户IDvalue是一个很大的对象但定时任务只负责塞不负责移除。时间一长老年代被这个Map撑爆Full GC持续CPU飙升。用MAT一看GC Root就是静态变量持有这个MapMap里全是历史用户对象。解决方式很简单用带过期策略的Caffeine缓存或者定时清理。这类问题属于“业务代码持有根引用”跟GC算法无关但排查看起来像是GC不会回收。面试时可以说JVM在算法层面不会因为循环引用漏标但业务层面的根引用失控才是内存泄漏的主要原因。4.3 面试高频追问一网打尽我把这段时间整理的高频GC面试题列一下附上回答要点问题回答要点哪些对象可以作为GC Root栈帧本地变量、操作数栈、静态变量、JNI引用、锁对象、活跃线程等什么是循环引用JVM如何解决引用计数解决不了JVM用可达性分析从GC Root出发遍历环内对象无根可达即回收三色标记法解决什么问题并发标记时区分对象状态保证不漏标不错标为什么需要写屏障和安全点写屏障拦截引用赋值记录并发修改安全点是线程能被挂起的位置用于STWCMS和G1的并发标记有什么区别CMS用增量更新G1用SATB什么是“漏标”黑色对象重新引用白色对象导致白色对象没被重新扫描浮动垃圾是什么并发标记过程中新产生的垃圾只能等下次GC回收为什么G1比CMS更适合大堆G1分区回收、可预测停顿、RSet管理跨区引用CMS碎片化和停顿不稳定面试官喜欢连环追问问到最后一句“你给我讲下Minor GC和Full GC分别会扫描哪些区域”这时需要答Minor GC只回收新生代但为了处理跨代引用会用到老年代的Card Table/RSet作为“伪根”Full GC则整个堆加元空间一起处理。能把这些串起来就说明你不是死记硬背。5. 我踩过的坑和几个实用建议5.1 循环引用代码在真实项目里别乱用虽然JVM能处理循环引用但我在代码审查时还是会建议同事尽量避免。原因不是回收不了而是代码可读性和生命周期难控制。比如两个实体互相关联如果都用强引用序列化时容易出现递归栈溢出。我见过fastjson序列化一个有父子互引用的对象直接抛StackOverflowError。解决办法是加JSONField(serialize false)或者用弱引用打破环。还有如果项目里用了某种内存数据库或者引用计数机制的外部库比如某些缓存中间件客户端循环引用照样可能导致资源不释放。别因为JVM支持就到处写环。5.2 三色标记法理解误区最容易踩的坑是“以为三色标记法只存在于CMS/G1并发GC”。其实任何可达性分析过程都可以抽象成三色标记模型只不过STW的GC由于没有并发修改三色转换非常短暂甚至感知不到。搞懂三色标记对理解G1并发标记、ZGC并发标记都有帮助。新一代ZGC也用了三色标记的变体配合染色指针。另一个误区是“灰色对象一定在标记栈里”。有些实现确实用标记栈存灰色对象但这不是三色标记法本身的约束只是常见实现。了解时要分清模型和实现。5.3 最后一组调优提示我在调优时一般遵循这个顺序先确认内存泄漏再调堆大小最后换GC器。顺序反了会白费功夫。很多人一上来就给老年代加内存或换G1结果内存问题没解决GC频率反而更怪。小经验如果应用只需要低延迟、小堆ZGC值得试但别在JDK 11早期版本上踩坑稳定版建议JDK 17。G1下调整-XX:MaxGCPauseMillis只是目标不是保证。别把它设成1ms否则JVM为了迁就目标会做出很多奇怪行为。如果Full GC后老年代依然占用80%以上先用MAT找泄漏别急着改参数。多数“Stop The World时间过长”跟堆太大有关但真正的元凶往往是大对象数组或字符串。用jcmdGC.class_histogram看看是否有超大数组。最后分享一个小技巧写Spring Boot应用时加一个/actuator/health定时上报JVM指标把jstat里的FGC、FGCT接入监控看板。很多应用出问题前Full GC次数会连续上涨提前看到就能提前处理。GC调优不是玄学是拿数据说话。你手里的jstat和MAT就是最实在的证据。
返回列表