
1. 先从“判断对象死活”说起写代码写了几年只要一碰到JVM调优或者线上内存告警绕不开的第一道坎就是垃圾回收。很多人背了一堆GC算法的名字面试能说出来标记清除复制标记压缩但真到了生产环境看着GC日志里那串数字却不知道该怎么下手。原因很简单基础算法的理解停留在表面不知道这些设计到底在解决什么问题又牺牲了什么。这篇内容我准备把JVM垃圾回收这条线完整梳理一遍重点放在四个算法上引用计数、可达性分析、标记清除、标记复制、标记压缩。适合正在准备面试的开发者也适合写了几年代码但从来没认真看过GC日志的人。搞清楚这套东西后面看CMS、G1甚至ZGC的文档都会轻松很多因为它们本质上都是在这些基础算法上演化的。垃圾回收要干的事其实只有两件第一找出哪些对象是垃圾第二把垃圾占用的内存收回来。第一步是判定第二步是回收。很多人在第二步上花了大量时间研究却忽略了第一步同样有讲究——判定方式的差异直接影响整个回收流程的设计。1.1 引用计数法最直观的方案也是最容易翻车的方案引用计数法的思路极其简单每个对象都配一个计数器记录还有多少个地方引用着它。当一个新的引用指向这个对象时计数器加一当某个引用失效时计数器减一。计数器变成零说明没人再需要这个对象了直接回收内存就行。这个方案的优势就在于简单和实时。代码实现容易因为计数是随引用变化即时更新的对象一旦变成零引用就能立刻回收不需要等到某个特殊的时机再统一扫描。不少脚本语言早期都采用过类似思路比如Python早期的垃圾回收就依赖引用计数。但问题也很致命最典型的就是循环引用。假设有两个对象A和BA内部有个字段指向BB内部有个字段指向A除此之外再没有别的引用指向它们。按照引用计数法A的计数是1被B引用B的计数是1被A引用谁都不会变成零。从程序逻辑上看这两个对象外部已经完全够不着了它们就是垃圾可计数器却显示它们被引用着。结果就是这两块内存永远回收不了形成内存泄漏。有解决办法吗有比如Python后来引入了额外的标记清除机制来处理循环引用。但这恰恰说明引用计数法本身不够自洽——你还需要另搭一套机制来兜底。在JVM里这个缺点是不可接受的所以主流JVM完全放弃了引用计数法改用可达性分析。除开循环引用引用计数还有个隐藏开销每次引用变化都要同步修改计数器在并发场景下还要考虑原子性这个开销摊到每一次赋值操作上累积起来并不小。1.2 可达性分析算法判断还有没有可能被用到可达性分析换了个思路不去统计谁引用了我而是从一组明确的根节点出发沿着引用关系一路走能走到的对象就暂时算作活的走不到的管你计数器是几通通当作垃圾。这组根节点专业术语叫GC Roots。主要包括这么几类虚拟机栈栈帧中的本地变量表中引用的对象方法区中静态属性引用的对象比如static变量方法区中常量引用的对象比如字符串常量池里的引用本地方法栈中JNI引用的对象被同步锁持有的对象比如synchronized的监视器锁对象打个比方可达性分析就像在一个仓库里找人先从几个确认的入口出发把所有通道都走一遍每个房间都看一遍遇到岔路就继续深入。最后没被找到的人就是失联了可以默认他有问题。可达性算法的关键优势就是解决了循环引用问题。因为判断标准是从GC Roots能不能到达A和B互相引用但外部没有入口那从根出发根本找不到它们直接就判死了。很多人还会追问一个细节可达性分析什么时候触发答案是在安全点SafePoint处。JVM为了精确地扫描对象引用关系需要所有线程都跑到一个明确的位置停下来保证此刻栈上的引用信息是完整的、一致的。这也是为什么GC日志里能看到停顿时间——Stop The World不是算法自己非要停而是可达性分析要求快照必须稳定。这里还要提一个在并发场景下至关重要的概念三色标记。可达性分析在执行过程中会维护三个集合——白色未被访问、灰色自身被访问但引用的对象还没被访问、黑色自身和引用的对象都被访问过。标记过程中如果同时发生引用变更比如一个黑色对象突然引用了某个白色对象就可能把这个白色对象误判成垃圾这就是漏标问题。现代垃圾收集器引入读写屏障、增量更新等手段本质上都是在解决这类并发标记下的正确性问题。2. 回收阶段三个经典算法各有各的取舍判定完存活对象接下来就是真正的清理阶段。这里也有三个经典算法它们是按照解决什么问题、牺牲什么代价的思路一步步演进过来的。2.1 标记清除算法思路最朴素但留下两个坑标记清除算法分两个阶段走先标记所有需要回收的对象再统一回收这些对象占用的内存空间。听起来简单实际用起来会发现两个明显缺点。第一个缺点是效率问题。标记阶段需要遍历所有的对象图清除阶段又要遍历一次堆空间重新确认哪些区域可以回收。两次全堆扫描规模一大开销就可观。第二个缺点是空间碎片。清除不是把内存清零而是把可回收对象占用的块标记为可用。这些块散落在堆的各个位置大小又不一致回收完了之后内存里到处是坑坑洼洼的碎片。后面再来一个大对象可能明明总空闲空间充足却连续找不到一块足够大的区域放下它这时候就会提前触发下一轮垃圾回收甚至直接分配失败。这在真实场景中非常头疼相当于你打扫房间把所有杂物挪开了但房间整个被占满后来想放个大衣柜却找不到一块完整墙面。因此标记清除算法在现代JVM里很少被单独使用往往是作为某些垃圾收集器的某个阶段出现比如CMS收集器在并发清理阶段用的就是标记清除的思路也因此CMS对碎片问题做了额外的处理策略——在特定条件下触发Full GC时进行碎片整理。2.2 标记复制算法空间换时间但要分对半标记复制算法的设计思路很妙把内存按容量分成大小相等的两块每次只使用其中一块。回收的时候把正在使用的这块上还存活的对象复制到另一块空的内存上然后再把原来那块整体清空。这个方案的好处是存活对象按顺序搬到另一侧内存排列是连续的没有碎片问题同时因为复制后直接清空整个半区不需要逐个判断哪些对象要回收效率非常高。在存活对象比例很低的情况下复制算法的表现几乎是所有算法里最漂亮的。但代价也很直白浪费内存。把堆内存分成两半等于只用了50%的可用空间有一半永远在那空着备用。另外如果存活对象很多复制的过程本身就成了大头开销搬动大量对象、更新引用关系反而拖慢速度。所以在新生代场景下这个对半分的方案被进一步优化了。JVM并没有真的把新生代劈成两半而是划分成一个较大的Eden区和两个较小的Survivor区常用的比例是8:1:1。为什么敢这么设计因为大多数对象都是朝生夕灭Eden区一次回收下来存活的常常不到10%那么用一个较小的Survivor区来承接这些残存对象就够了。真正转到老年代的对象只是少数。这样一来复制算法的浪费率就从50%降到了10%代价是当Survivor区装不下时那些对象就得走分配担保机制升入老年代。2.3 标记压缩算法对抗碎片的终级方案标记压缩算法的前两步和标记清除一样标记存活对象。但第三步变了——它不只是清除垃圾而是让所有存活对象向内存一端移动然后直接清理掉边界另一侧的整块区域。这个设计听起来和复制算法有点像都是把存活对象聚拢但关键区别在于复制算法需要一块额外的空区域作为目的地而标记压缩是在同一块内存内完成的移动不需要额外的比例牺牲它可以把整块堆空间都用起来。代价是什么移动对象意味着要更新所有指向这些对象的引用这个过程比较耗时而且由于它没法像复制算法那样整体清空一个区域来加速在存活对象多的时候压缩阶段的开销会明显高于复制算法。标记压缩的收益在于回收完成后堆内存是一块连续的空闲区域后续分配大对象非常顺畅老年代长期运行的碎片问题得到根治。所以老年代的垃圾回收实际上就是要么用标记清除快速回收但积累碎片要么用标记压缩整理空间但付出移动代价的权衡选择。2.4 三种回收算法横向对比算法原理优点缺点典型适用场景标记清除标记垃圾后直接释放简单不需要移动对象碎片严重两次扫描效率低老年代部分收集器的清理阶段标记复制存活对象复制到空区域整体清空旧区域无碎片回收效率高浪费内存存活率高时开销大新生代标记压缩标记存活对象并向一端移动清理边界外区域无碎片内存利用率高要移动对象、更新引用停顿时间长老年代3. 为什么是分代收集算法间的配合艺术如果只单纯运行某一种算法很难同时兼顾回收效率和空间利用率。于是JVM发明了分代收集的思想根据对象存活特征的不同把堆划分为新生代和老年代各用各的算法。3.1 新生代复制算法的主场新生代对象的特点是命短。绝大多数对象在创建后很快就失去引用成了垃圾。所以新生代每次垃圾回收Minor GC能清掉很大比例的对象剩下的存活对象很少非常适合复制算法。整个新生代被规划为一块较大的Eden区域和两块较小的Survivor区域通常叫From和To。对象创建时先放进EdenEden满了触发Minor GC回收时把存活对象复制到From区清空Eden。第二次GC时把Eden和From的存活对象复制到To区清空Eden和From然后From和To的角色互换——谁空谁当To。这个来回倒腾的过程每一次都保证有一块Survivor是干净的留作下次复制的目标区。这也是为什么JVM参数里有个-XX:SurvivorRatio你把它理解成Eden和单个Survivor的大小比例就好默认8表示Eden:From:To 8:1:1。我自己调优时习惯先看一眼对象晋升统计如果Survivor频繁溢出说明存活对象比预期多可能需要适当调大Survivor或者调整晋升阈值。3.2 老年代清除压缩的取舍之地进入老年代的对象要么是从Minor GC中熬了很多轮还没死要么是大对象直接分配进来的。它们的共同特点是存活时间久、存活率高。此时如果还套用复制算法每次GC都要搬运大量对象反而成为开销。而且老年代空间大对半分的做法也太奢侈。所以老年代用标记清除或标记压缩。你在实际GC日志里看到的Major GC和Full GC很多就是用了标记压缩来整理这块区域。具体用哪种取决于垃圾收集器的实现策略比如Parallel Old收集器就是典型的标记压缩而CMS主要用并发标记清除只在必要条件下才做压缩。3.3 现代垃圾收集器怎么套用这些算法理解了基础算法再看近几年的收集器就非常顺了。Parallel Scavenge Parallel Old新生代复制老年代标记压缩追求高吞吐适合后台计算型任务。CMS基于标记清除核心变化是引入了并发标记让标记阶段和用户线程尽量并行。CMS的明显短板就是碎片所以才需要额外开启-XX:UseCMSCompactAtFullCollection在Full GC时做压缩整理。G1把堆划分为一个个Region逻辑上仍然分代但回收时以Region为粒度优先回收垃圾最多的Region。有的Region用复制有的用压缩本质上是把复制算法的思想用到了更精细的粒度上。ZGC层次更高了引入了染色指针和读屏障把标记和压缩的过程与业务线程的停顿降到极低但底层的对象存活判定依然走的是可达性分析最终的内存整理依然带着标记压缩的影子。所以说掌握了那三个基础算法你看任何收集器的官方文档看到的不是新算法而是这三个算法的改造版和并发化版本。4. 线上实战GC日志怎么看、问题怎么排查理论说完聊聊实际中用到的东西。很多同学看GC日志一脸懵主要原因就是不知道日志里的字段对应哪些阶段的动作。我们模拟一条常见的日志来看[GC (Allocation Failure) [PSYoungGen: 61312K-6123K(64512K)] 61312K-26055K(252800K), 0.0123456 secs] [Times: user0.03 sys0.00, real0.02 secs]这条日志透露了几个信息PSYoungGen回收的是并行收集器管理的新生代。61312K-6123K(64512K)新生代回收前占用61MB回收后占用6MB总容量64MB。61312K-26055K(252800K)整个堆的占用变化回收前61MB回收后25MB堆总容量252MB。0.0123456 secs这次Minor GC停顿约12毫秒。如果你看到Full GC字样频繁出现而且老年代容量一直在高位通常只有两种可能要么是内存泄漏要么是分配的内存预算真的不够。我自己排查时第一步从来不看代码先看堆转储。实践中整理过一张典型的排查流程可以照着做用jstat -gcutil pid 1000持续观察每个分区的使用率确认是不是老年代持续走高。如果老年代Full GC后回收效果不佳用jmap -dump:formatb,fileheap.hprof pid抓一份堆快照。用MATEclipse Memory Analyzer打开堆快照重点看支配树Dominator Tree上占内存最大的对象再根据引用链定位到业务代码。依据类名和字段名反推问题位置。有一次线上Full GC频繁追根溯源发现是一个缓存Map只往里写没有设置淘汰策略几千个线程各写各的瞬间就把老年代塞满了。解决方案很简单加个基于容量的淘汰策略或者引入专门的缓存框架。还有一种常见情况是元空间Metaspace触发Full GC。元空间存的是类元数据常见诱因是热部署或者动态生成太多代理类。这种Full GC的特征是GC日志里Metaspace占用一路上涨StackOverflow一搜全是动态代理反射导致Metaspace溢出的案例。解决方向一般是排查是否有类加载器泄漏或者调整-XX:MaxMetaspaceSize做兜底限制。4.1 常见问题速查表现象可能原因排查方向常用手段Minor GC频繁Eden设置过小或对象分配过快看对象分配速率适当调整堆大小检查是否有大对象Full GC频繁老年代空间不足 / 内存泄漏堆转储分析调整堆配置修复泄漏点GC后老年代占用率仍然很高对象大量晋升 / 动态年龄阈值设置不当观察晋升统计调整-XX:MaxTenuringThreshold系统响应缓慢GC停顿时间长垃圾收集器选择不合适 / 堆过大对比各收集器停顿切换到G1或用ZGC低停顿方案Metaspace不停增长类加载器泄漏 / 动态生成类过多监控类加载数量检查代码限制动态生成4.2 三个易踩的误区第一个误区是把System.gc()当作灵丹妙药。System.gc()在大多数实现里只是JVM的一个建议JVM完全可以忽略它。即使真的触发了也是一次Full GC代价极高不建议在业务代码里随意调用。第二个误区是认为对象进入老年代就是坏事。严格来说一个对象如果活过了多轮Minor GC它进入老年代是正常生命周期的一部分。真正要警惕的是刚创建的大对象直接进老年代这种对象可能只是临时用一下却占用了老年代空间间接加剧了Full GC频率。第三个误区是混淆强引用和可用内存。JVM的垃圾回收严格以GC Roots可达性为准并不是指向它的引用少了就马上回收。所以开发时不要依赖把引用置为null就立刻释放内存这种直觉让对象失去引用、离开作用域才是根本。4.3 调优思路的一点个人经验很多同学一遇到GC问题第一反应是加内存。加内存确实能缓解频率但如果真实问题是泄漏加多少都会被打满还不如先花半小时抓一次堆转储往往一次分析就能定位问题。如果堆内存设置本身没问题还是要从吞吐量和停顿时间这两个维度去权衡。后台批处理系统对停顿不敏感可以选Parallel收集器把吞吐量拉满在线交互系统更在意响应时间G1甚至ZGC更合适。调参时不要一上来就动一堆参数每次只改一个观察一个周期再决定下一步。这种单变量法排查GC问题非常有效我踩过几次坑之后基本固定了这个思路。5. 从算法演进里看JVM的工程哲学把这几个算法串起来看其实能发现一条清晰的工程决策线每一步都在更高效和更安全之间做权衡。引用计数实时但解决不了循环引用可达性分析正确但需要停顿标记清除简单但碎片多复制算法无碎片但浪费内存压缩算法不浪费内存但要移动对象。分代收集则进一步根据对象的生命周期差异把不同算法的优势组合到同一个堆的不同区域。理解这条演进线比背下每个参数的默认值值钱得多。因为线上环境千差万别你今天查到的参数默认值换个JDK版本可能就变了但垃圾回收的底层设计思路不会变。遇到不通用的场景顺着这条决策线去推理往往能比盲目搜资料更快找到答案。这也是每一个做Java后端的人值得花时间把垃圾回收这块基础打牢的原因。