资讯动态

JVM垃圾回收算法详解:引用计数、可达性分析与标记压缩全解析

发布时间:2026/10/24 5:07:17 来源:尧图企业网站定制
做Java开发这些年我见过太多人被垃圾回收折磨得睡不着觉。不是骂它逻辑不对而是搞不懂内存里到底发生了什么。其实把JVM垃圾回收算法那几个关键词吃透大多数GC问题就有了方向。引用计数、可达性分析、标记清除、标记复制、标记压缩这五个词看着像面试题背诵手册但它们背后是一条完整的问题解决链路先搞清楚对象什么时候该死再决定怎么把尸体清掉最后处理清完之后留下的坑。这套链路决定了JVM堆内存的整体运行策略也决定了你在线上遇到Full GC的时候到底是该调参还是该改代码。这篇文章我会从内存结构开始讲把五种算法的原理、优点、缺陷、应用场景全部拆开揉碎最后用实战排查的经验收尾。适合所有被OOM困扰过的Java工程师也适合准备面试但不想死记硬背的人。我尽量把每个算法讲成一段故事而不是一段定义。1. 垃圾回收的地基内存结构、分代模型与GC Roots1.1 先搞清楚垃圾回收到底在“哪里”干活JVM的内存区域分成程序计数器、虚拟机栈、本地方法栈、方法区和堆。前三个属于线程私有跟着线程生、跟着线程死基本不用垃圾回收器操心。方法区虽然也有回收需求比如类的卸载但那是极其低频的事绝大多数情况下垃圾回收的主战场只有一个堆。堆里装的就是你new出来的对象包括大部分数组、集合元素、业务对象实例。你写的代码里每创建一个对象JVM就在堆上给它分配一块内存。对象活着的周期各不相同有的在方法里声明完就死了有的被静态变量长期引用能活到进程结束有的则在一个请求里来回流转。垃圾回收器要做的就是从这一堆杂乱的对象里找出“没人要的”那些把内存腾出来给新对象。因此搞清楚堆的结构是理解垃圾回收的第一步。堆通常分成新生代和老年代新生代里又细分为Eden区、Survivor0区、Survivor1区。这个划分不是随便拍脑袋定的它基于一个统计规律绝大多数对象的生命周期极短活不过几次年轻的垃圾回收。把短命对象集中放在新生代把长命对象晋升到老年代回收时就可以区别对待效率高得多。1.2 分代假设与内存划分新生代、老年代与GC分类先看新生代Eden区是对象出生地绝大多数对象刚new出来就落在Eden。Survivor区相当于临时收容所对象在Eden熬过一次回收后如果没死就挪进Survivor区。Survivor分了两块S0和S1每次只用一块另一块空着目的是做复制回收。后面讲标记复制算法时你会看到这两块区域就是为复制准备的。老年代存放的是那些在Survivor区苟过了若干次回收、年龄达到阈值默认是15的对象或者某些大对象直接晋升。老年代对象存活率高回收频率低但对内存空间的完整性要求更高因为一旦老年代碎片化严重大对象就找不到连续空间分配了。基于这种分代结构GC也分成几类Minor GC发生在新生代收集Eden和Survivor区频率高、速度快。Major GC一般指老年代回收但在不同的实现里叫法有些出入。Full GC对整个堆和方法区进行回收停顿时间长是最让人头疼的事件。Mixed GCG1收集器特有的既回收新生代也回收部分老年代的Region。理解这几类GC是后续排查的基础。你在线上日志里看到“Full GC (System.gc())”或者“Allocation Failure”之后跟着老年代回收就得有意识地意识到新生代已经扛不住对象分配了或者老年代空间不足了。1.3 GC Roots是什么可达性分析的起点聊可达性分析算法之前必须先说清楚“从哪里开始找”。所有存活对象的起点我们叫GC Roots。这套起点集合包括虚拟机栈中局部变量表里引用的对象、方法区中静态属性引用的对象、常量引用的对象、JNI本地方法引用的对象以及活跃线程相关的内部引用。GC Roots的选取原则是要保证从这些根出发能够触及所有“仍然被使用者持有的对象”。用大白话说程序还在用的东西必须从某个根出发能找得到。比如你在方法里定义了一个局部变量User user new User()这个User对象就被虚拟机栈里的局部变量引用了它就是可达的不会被回收。而一个new出来后就没人存的匿名对象无法从任何根出发找到那它就会被判定为垃圾。有一次我在排查内存泄漏时用工具dump堆快照发现一个集合莫名其妙地膨胀到几百万条数据。最后定位原因竟然是某个类里的静态Map不断往里塞数据从来没清理过。这个Map就是一个根它引用的所有对象都从根出发可达于是越积越多老年代塞得满满当当。这就是从GC Roots角度理解内存问题的最典型场景。2. 判定对象死亡的两种思路引用计数法与可达性分析2.1 引用计数法思路很直但坑很深引用计数法是最直观、最朴素的设计。它的核心逻辑是给每个对象配一个计数器每当有一个地方引用它计数器加一引用失效时计数器减一计数器的值变成0就说明这个对象没人要了立刻回收。好处是简单实现成本低判定效率也高而且没有“暂停所有业务线程”的阶段因为对象死没死是实时计算出来的。Python的早期版本和很多脚本语言就采用过类似思路。但引用计数有一个绕不过去的死结循环引用。假设对象A里有一个字段指向B对象B里同时也有一个字段指向A。除了这俩互相指之外外面没有任何变量引用它们。那么A的计数为1因为B引用了它B的计数也为1因为A引用了它。它们的值永远到不了0哪怕这俩对象已经彻底和外界失联垃圾回收器也拿它们没办法。我在实际开发里遇到过类似的内存泄漏一对互相保存对方引用的业务实体从业务角度已经删除了但因为循环引用内存始终释放不掉。虽然JVM不用引用计数法但在某些非JVM环境里写代码时依然要小心这种环结构的清理问题。这就是为什么JVM的主流派系没有选择引用计数而选择了可达性分析。引用计数只能处理“没有引用”的对象处理不了“引用成环”的对象这个结构性缺陷让它不够格当JVM的默认判定算法。2.2 可达性分析从GC Roots出发找活路可达性分析算法的思路是从一系列叫GC Roots的节点出发沿着引用关系往下走整个引用关系就构成一张图。凡是能从GC Roots出发走到的对象都是“活”的走不到的都是可回收的候选者。这个算法对循环引用的处理能力是质的飞跃。回到A和B互相引用的例子从GC Roots出发你找不到通向A的路径也找不到通向B的路径那它们俩就是不可达的。即便它们互相指得再紧在可达性分析看来依然是死路。这就是它相比引用计数的核心优势。可达性分析还有一个需要理解的细节不可达不等于立即死亡。JVM会给对象一个“缓刑期”也就是finalize()机制。对象第一次被标记为不可达时会放进一个队列后续如果有机会执行finalize()对象可以把自己引用给某个GC Roots实现自我拯救。但是我要掏心窝子劝一句永远不要依赖finalize()来做资源释放或自救。它的执行时机不确定甚至可能不执行而且执行顺序无法保证。你在finalize()里写什么逻辑都等于给垃圾回收的确定性埋雷。Java官方早就把它标记为废弃思想现代代码里就应该彻底遗忘它。现代JVM的可达性分析还涉及一些优化手段比如用OopMap记录栈上哪些位置是引用用安全点Safepoint让所有线程停下来时状态一致。这些都是为了在“暂停所有业务线程”这个前提下尽量缩短垃圾回收对业务的影响时间。2.3 对象“真正死亡”的两次标记与finalize自救严格来说一个对象被判定为不可达要经过两次标记。第一次标记后如果对象没有覆盖finalize()或者finalize()已经被调用过那它就直接进入回收队列。如果对象覆盖了finalize()且从未执行过它会被放到一个低优先级的队列里等待JVM派发执行。finalize()执行的时机是随机的执行过程中对象可以重新引用自己把自己重新挂到引用链上。但如果第二次标记时对象还是没救回来那就彻底完蛋了。这个设计在历史上是为了给某些需要兜底释放的外部资源一个机会但代价是引入了不确定性。在实际运维中我从来不看代码里有没有finalize()因为一旦业务代码里出现这玩意儿就意味着GC行为变得不可预测。如果你在维护老代码时碰到finalize()做了资源释放的最好的做法是把相关代码挪到try-with-resources或显式close()里把确定性还给程序。3. 回收阶段的三板斧标记清除、标记复制、标记压缩3.1 标记清除算法简单有效但会留一地碎片标记清除算法要干两件事先标记再清除。标记阶段顺着可达性分析的结果把所有不可达对象标个记号清除阶段把带记号的直接内存释放掉。整个过程不移动对象实现上最直白。它的优点就是简单不需要额外空间也不用搬动存活对象。但它有两个非常要命的缺点。第一效率问题。标记和清除两个阶段都需要遍历所有对象GC停顿时间随堆大小线性增长。堆越大停顿越猛。你想想一个几十GB的堆光标记就得扫多久。第二内存碎片化。清除之后存活对象之间的空隙是一段一段的不连续。下一次想分配一个大数组或大对象时如果找不到一块连续空间即使总的空闲内存足够分配也会失败这会直接触发新一轮垃圾回收甚至强制Full GC。我排查过一些线上应用堆内存明明还剩30%但大对象就是分配失败日志里全是Allocation Failure原因就是老年代碎片太多。碎片化问题本质上和清运垃圾一样——你只把垃圾捡走但房子里家具东倒西歪后面的东西就没法整齐摆放了。3.2 标记复制算法用空间换时间标记复制算法的核心思想把内存按容量分成两块每次只使用其中一块。回收时把存活对象逐个复制到另一块然后把整块旧空间一次性清空。乍一听很浪费但它的收益很可观复制后的内存是连续排列的没有碎片分配新对象时只需要一个指针往前进效率极高而且回收只针对整块区域不用频繁遍历。它有个著名缺点空间浪费。如果按1:1划分那一半内存永远空着。所以JVM在新生代没有采用1:1的平均划分而是把Eden和Survivor比例设计成大约8:1:1。这样总共浪费的新生代空间只有10%换来了还算不错的空间利用率和高速回收。但复制算法还有一个更务实的代价当存活率高时一切就不那么美了。试想一个区域里90%都是活对象复制90%的对象到另一块空间工作量很大而清掉的垃圾只占10%性价比极低。所以复制算法只适合“朝生夕死”的新生代不适合老年代老年代的对象普遍寿命长要是每次GC都复制一大批活对象系统早就不堪重负了。新生代使用复制算法时还会配合“分配担保机制”。正常情况下Survivor区只有一块万一Minor GC后存活对象比Survivor区容量还多多出来的对象就会直接通过一种担保逻辑进入老年代。这就像你临时借宿的朋友太多小客房住不下只能往大客厅里挤。3.3 标记压缩算法把活着的东西码齐标记压缩算法可以理解为“标记清除的加强版”。它的标记阶段和标记清除一样但清除过程变成了整理把存活的对象向内存空间的一端移动按顺序排列然后把边界之外的部分一次性清空。这个设计的直接好处是没有碎片。所有活对象被码得整整齐齐空闲内存变成一大块连续空间大对象分配不再犯难。同时它不需要像复制算法那样预留一整块空区域内存空间利用率高。代价是什么对象移动本身就是大工程。移动存活对象意味着要修改栈上、寄存器里、本地方法里所有指向这些对象的引用这些引用的更新必须在GC停顿期间完成。对象越多移动越久STW越长。所以在老年代执行标记压缩通常停顿要比新生代复制长一个数量级。关于标记压缩的实现经典的Lisp2算法用三遍扫描来记录转发地址并更新引用内存开销是对象头里加一个指针。还有一种两遍扫描的碰撞指针法不额外占用内存但需要扫描全部指针。这些实现细节说明一个道理压缩不是白用的它是用“停多久”换“未来分配多顺畅”。3.4 三种回收算法横向对照| 算法 | 是否移动对象 | 是否额外空间 | 是否有碎片 | 适用区域 | 核心代价 | | 标记清除 | 否 | 否 | 有 | 老年代早期CMS | 碎片导致分配困难 | | 标记复制 | 是 | 预留一半空间 | 无 | 新生代 | 空间浪费、存活率高时成本高 | | 标记压缩 | 是 | 否可能需辅助指针 | 无 | 老年代 | 移动对象更新引用STW长 |这张表是我面试新人时最常拿出来问的。能把三种算法的代价和适用场景讲清楚的人说明真的理解分区与算法之间的匹配逻辑。现在的JVM收集器都没有只用一种算法。新生代普遍用复制老年代要么用标记清理要么用标记压缩甚至同一台机器上不同代的回收是不同算法组合起来的。理解单个算法是基础理解怎么组合才是实战能力。4. 从理论到实践主流垃圾收集器如何组合这些算法4.1 串行与并行回收器起步与吞吐量优先串行回收器Serial是最古老的收集器。它回收时只有一个GC线程做标记、复制、清理线程数就一个回收期间所有业务线程都停着。单核环境或者堆特别小的时候它的表现其实可以接受因为线程切换开销少单线程反而稳定。它内部使用的就是新生代标记复制加老年代标记压缩的组合。并行回收器Parallel Scavenge是串行的多线程版本主打吞吐量优先。所谓吞吐量就是运行用户代码的时间占总时间的比例。它特别关注两个参数-XX:MaxGCPauseMillis用来限制最大停顿时间-XX:GCTimeRatio用来设定吞吐量目标。默认以吞吐量优先时JVM会在停顿时间和吞吐量之间做折中。并行回收在实际应用中适合那些后台批量任务、离线计算之类的场景它们不要求毫秒级响应但希望在单位时间尽量多干活。换句话说这种场景宁愿GC经常停一下也不希望每次GC停太久之后总的运行时间被拖长。4.2 CMS收集器标记清除的“并发改良”CMSConcurrent Mark Sweep收集器是标志性的老年代收集器第一次把“并发”概念带上GC舞台。它把老年代回收拆成四个阶段初始标记、并发标记、重新标记、并发清除。其中初始标记和重新标记需要STW但耗时极短真正耗时的并发标记和并发清除阶段GC线程和应用线程一起跑业务不至于被整个卡住。CMS用的是标记清除算法不移动对象。这带来一个让人头疼的结果碎片化。运行时间一长老年代碎片遍地都是。CMS的应对办法是开启压缩整理比如设置参数让Full GC期间对老年代做一次碎片整理但这样停顿时间会明显变长。CMS还有浮动垃圾问题。并发标记阶段应用程序同时在跑一边标记一边可能有新垃圾落地这些垃圾这一轮标记看不到只能留到下一轮。所以CMS必须预留空间给这些浮动垃圾而且如果预留空间不够触发一次恐怖的Concurrent Mode Failure直接退化到Serial Old做Full GC停顿时间飙上十几秒。这也是CMS被淘汰的主要原因。4.3 G1混合回收与复制算法的大规模实践G1收集器是更现代的选择它的设计目标非常激进在多核大内存机器上实现可预测的停顿时间同时维持高吞吐量。G1放弃了物理上严格分代的做法把整个堆划分成许多大小一致的Region每个Region在逻辑上可以扮演Eden、Survivor或者老年代。G1的年轻回收还是用复制算法把Eden和Survivor区的存活对象复制到新的Survivor或老年代Region里。老年代回收则采用了Mixed GC的方式在一次回收中同时处理部分老年代Region和新生代Region。它维护全局的存活对象信息和标记数据回收时优先清理垃圾占比高的Region这就是“Garbage First”名字的来历。G1在并发标记阶段使用了Snapshot-at-the-BeginningSATB技术记录并发标记开始时点的对象图快照。它能保证在并发标记期间新分配的对象不会被错误地判定为垃圾从而避免回收过程中丢失活对象。这个机制解决了CMS浮动垃圾的根本性缺陷也使得G1能在混合回收时更从容地处理老年代垃圾。我用G1遇到过最典型的调优场景是业务接口的TP99延迟突然抖动。查看GC日志发现Mixed GC的停顿时间超过预期后来通过调低-XX:MaxGCPauseMillis让GC更频繁但每次更短反而让接口延迟稳定下来。这里要注意目标停顿时间设得越低G1的回收频率越高整体CPU开销也越大必须结合业务指标做权衡。4.4 收集器选型与参数调优的实用建议大多数人面对GC选型的第一反应是找“最好”的收集器实际上没有最好的只有最合适的。我自己的判断标准是先看业务指标再看硬件配置。如果服务对延迟极其敏感比如交易链路、实时推荐ZGC或Shenandoah这类超低延迟收集器更合适如果服务是批处理任务Parallel是稳妥选择如果只是普通在线服务G1是当前JDK版本的默认选项基本不会错。JDK版本的默认配置是一个很重要的基础事实不同版本的JVM自带不同的垃圾回收器。比如JDK 8的默认是ParallelJDK 11之后G1成了默认而JDK 17之后ZGC在很多平台也变得越来越常用。所以你在排查GC问题时第一步应该是确认当前应用到底用的是哪个收集器而不是上来就设计一堆参数。调优的顺序也很关键先使用监控平台或者jstat观察到GC频率和耗时再打开GC日志比如-Xlog:gc*:filegc.log的方式记录每次GC前后的堆占用和停顿最后根据数据调整参数。不要一上来就加堆内存堆内存加得过大会造成单次GC停顿时间暴涨反而得不偿失。还有一个小建议尽量把堆里新生代的大小控制在一个合理范围。如果新生代太小Minor GC太频繁如果太大老年代就会被压缩Full GC间隔变短。通常好的做法是让新生代占总堆的三分之一到一半左右然后观察Minor GC频率是不是在几秒到十几秒一次再慢慢微调。5. 常见问题与排查技巧实录5.1 循环引用真的会被回收吗这是我能想到最经典的面试题也是很多人绕不过去的坎。在JVM里答案是会。因为JVM默认采用可达性分析不是引用计数环状引用关系没有根可达就一起回收。我在分析一些老项目的堆快照时经常看到成环的缓存对象它们占用的内存其实是可以回收的问题反倒是那些“有根可达但业务上认为没用了”的对象它们才是内存泄漏的真凶。排查循环引用和内存泄漏的最佳工具是堆快照分析比如用对象引用链展示功能从某个GC Root出发看哪些对象被意外地保住了。如果你在快照里看到一个Map结构持有大量对象而这些对象的引用者是一条意想不到的路径那你基本就找到泄漏点了。5.2 频繁Full GC或者内存碎片导致分配失败怎么办Full GC频繁的根因五花八门但排查思路是可复用的。首先看业务线程创建对象的速度是否过高比如在热点接口里循环new大对象、大数组这可能就是元凶。其次看堆分配是否合理老年代空间不够但又没有明显泄漏往往是晋升阈值设置不当导致短命对象提前进入老年代。最后看是否有外部缓存或静态集合在持续增长。排查的时候我习惯这样走用jstat-gcutil观察各区的使用率和GC次数。打开GC详情日志确认每次Full GC前后的内存变化。再不行就dump堆快照用内存直方图统计类实例数量和占用大小。如果看到某个自定义类实例数量异常沿着引用链往上追找到谁在引用它。遇到碎片导致的分配失败就观察GC日志里是否有“promotion failed”或者类似信息。这类表现说明老年代碎片化到无法分配晋升对象通常需要考虑切换到压缩型的收集器或者通过参数启用老年代整理。5.3 CMS碎片化怎么破老项目还在用CMS的场景不少。碎片化是CMS最头疼的问题它在默认策略下不会像标记压缩那样整理老年代。我常用的止损手段是开启-XX:UseCMSCompactAtFullCollection旧版本JDK中让Full GC时顺便做一次压缩。但要注意压缩阶段会加倍停顿时间所以它只能作为兜底手段。更好的建议是如果你的服务还在用CMS而且经常因为碎片化触发并发模式失败我建议趁早迁移到G1或者ZGC。这类迁移对业务透明只需要修改启动参数再配合一段时间的灰度观察即可。我一个同事迁移后Full GC次数从每小时几次降到几乎没有效果立竿见影。5.4 STW时间过长的止损技巧STW时间过长的直接感受是接口超时、服务卡顿。止损的第一步永远是“让GC快点结束”不是“优化代码”。如果你当前堆是几十GB的老年代一次压缩整理可能要好几秒那最直接的办法是缩小单次GC的堆范围或者切换到增量回收的收集器。另外有一个经常被人忽略的招数检查是否有显式的System.gc()调用。某些框架会在RPC调用前后或者定时任务里偷偷调用它导致Full GC频繁发生。排查时先加-XX:DisableExplicitGC看症状是否消失。如果确实是因为显式GC调用禁用之后观察是否还有其他依赖这种调用的逻辑再做使用策略调整。5.5 一套排查工作流把前面的零碎经验串成一条工作流我的习惯是这样的第一步确认指标。通过监控平台或者jstat看GC频率、GC耗时、堆使用率曲线判断是内存泄漏还是短暂的分配高峰。第二步抓现场。打开GC详情日志记录Full GC发生前后的内存数据。如果OOM就利用-XX:HeapDumpOnOutOfMemoryError在OOM时自动dump堆快照。第三步分析根因。把堆快照装进分析工具看哪些对象占了最多的内存引用链是从哪里出发的。这一步要有耐心不要急着下结论因为很多时候对象数量最大的是byte[]你得继续分析谁创建了这么多byte[]。第四步验证策略。改完代码或者改完参数后跑一轮压力测试对比GC指标和业务RT变化确认优化有效再发布。这套工作流我在多个项目里复用每次都稳。真正踩过的坑反而都在第一步如果基础监控数据不准后面分析全都白搭。所以我建议先把GC日志接入日志平台每天定期观察趋势等出了问题再回头翻往往线索都在历史日志里。最后再分享一个小技巧做GC调优时不要贪心一次把所有参数都改掉。每次只改一个变量比如堆大小、或者新生代比例、或者目标停顿时间然后观察一两天用数据说话。垃圾回收算法本身是成熟的你的应用才是变量把应用的行为吃透比背一堆参数更有用。我自己早期喜欢一股脑儿堆参数结果经常导致性能不升反降后来学会“单变量控制”再也没为调参的事抓狂过。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑