一次讲透Java垃圾收集:从GC Roots到CMS、G1、ZGC的设计取舍

发布时间:2026/10/17 13:42:33
一次讲透Java垃圾收集:从GC Roots到CMS、G1、ZGC的设计取舍
前几天有人拿着一份“Java垃圾收集八股清单”来找我开门见山地问“你能不能一句话说清楚CMS和G1的区别”我的第一反应不是去背区别而是想如果连标记-复制、标记-清除、标记-整理这三种垃圾收集算法都还没吃透那背下来CMS和G1的区别也只是给自己打了一个看起来很结实的死结。这些年我排查过不少线上服务的GC停顿问题一个很深的感受是垃圾收集算法和垃圾收集器本质上是同一道工程题的两种回答——你到底愿意花多大的吞吐量代价去换取多低的停顿时间。这篇笔记我不打算做成又一个“名词背诵手册”而是把对象怎么判定死亡、三大经典算法怎么取舍、各代收集器怎么组合、以及当下主流的G1和ZGC为什么长成现在这样按一条线串起来。适合还没形成体系的新人反复看也适合准备面试的人当作自查清单。1. 对象判死根可达性分析与四种引用强度的实际含义1.1 引用计数法为什么被JVM放弃很多教科书一上来就讲引用计数法说它实现简单、执行高效每个对象维护一个计数器被引用一次就加一引用失效就减一计数器归零就可以回收。听起来很完美但它有一个绕不过去的死结循环引用。假设A对象持有B对象的引用B对象也持有A对象的引用除此之外再没有别的路径能触达它们。这时候A和B的引用计数都是1它们谁都不会归零但实际上它们已经是一堆没人要的垃圾。常用的“对象两两引用”场景还只是小问题真实系统里一旦出现稍微复杂的对象图循环引用就会让内存悄悄泄漏。JVM最终选择了根可达性分析思路是反过来不从对象自身出发数引用而是从一组明确的“根”出发顺着引用链往下走能走到的对象就是活的走不到的就是垃圾。把问题从“我有没有被人引用”变成“我是否还被外部世界需要”循环引用自然就破解了。1.2 GC Roots到底有哪些可达性分析的起点必须稳定、可信不能本身也在堆里面存活的普通对象否则就会变成“互相当根”。JVM里可以作为GC Roots的主要有几类。虚拟机栈里局部变量表引用的对象。这是最常见的一类正在执行的方法持有的局部变量、入参、临时对象都算。静态变量指向的对象。类级数据往往是全局的只要类没被卸载静态引用就是根。被synchronized持有的锁对象。锁本质上也是资源正在参与同步的对象不能被回收。JNI本地方法栈里引用的对象。各种JVM内部使用的对象比如系统类加载器、常驻的异常对象等。这里有一个容易被忽略的细节GC Roots本身不是恒定不变的。线程栈会随着方法调用不断变化里面对象的存活状态也在变化所以每次GC的根集合都是重新计算出来的。这也是为什么GC过程中经常需要暂停用户线程根集合必须在一个稳定快照上扫描。1.3 四种引用强度不只是面试题是内存策略从面试角度四种引用已经很“八股”了强引用、软引用、弱引用、虚引用。但从实际工程看它们其实是两个目的允许“按需回收”的缓存策略和对象生命周期结束后的“通知机制”。强引用正常代码里最常见的引用。只要强引用还在对象永远不会被GC回收。软引用在内存即将溢出前JVM会把只被软引用指向的对象回收掉。适合做缓存比如图片缓存、二级缓存空间吃紧时先把它们丢掉。弱引用比软引用更短命下一次垃圾回收就会把只被弱引用指向的对象回收掉。适合做“可有可无”的数据。虚引用最特殊get()方法永远拿不到对象它的作用是当对象被回收时能通过引用队列收到一个通知用来做资源清理的兜底。下面这个弱引用配合引用队列的写法在实现一些“对象使用完自动释放资源”的组件时会看到。public class WeakRefDemo { public static void main(String[] args) { Object value new Object(); ReferenceQueueObject queue new ReferenceQueue(); WeakReferenceObject weakRef new WeakReference(value, queue); // 手动清除强引用让对象只剩弱引用 value null; // 触发GC后对象被回收引用进入ReferenceQueue System.gc(); Reference? ref null; try { ref queue.remove(500L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } if (ref ! null) { System.out.println(对象已被回收收到清理通知); } else { System.out.println(这次回收还没发生); } } }注意System.gc()只是向JVM提出建议不保证立即执行。实际项目里更常见的做法是让资源本身继承Cleaner或显式释放而不是依赖finalize。1.4 finalize这个坑能不用就别用很多老资料都会讲finalize()方法对象不可达时如果覆盖了finalize()且还没执行过会先进入一个低优先级的队列由Finalizer线程执行。听起来像是给对象一个“复活”机会但工程上它几乎全是缺点。第一执行时机不可控线程可能一直排在后面导致本应回收的对象迟迟不能释放第二如果finalize()内部执行慢、或抛出异常可能拖累整个Finalizer线程第三对象在finalize()里给自己重新赋上引用确实会“复活”但复活的代价是让GC机制变得难以预测。我之前排查过一个古怪的内存增长问题最后发现是多处代码依赖finalize做流关闭有些流没被及时执行文件句柄都快耗尽了。现在JDK已经把finalize标记为废弃官方也建议用显式的close()配合try-with-resources或者用Cleaner做清理。面试中如果被问到“对象什么时候可以被回收”不要只背“可达性分析不可达”还应该补一句“覆盖了finalize并且已经执行过一次的对象第二次不可达时才会真正被回收”——这句细节往往比背概念更让面试官觉得你是真读过书的。2. 三种经典收集算法本质是“时间/空间/碎片”的三角选择2.1 标记-清除最简单的思路最先暴露碎片化问题标记-清除算法分两个阶段先顺着GC Roots把活对象标记一遍然后遍历整个区域把没标记的对象清除掉。思想非常简单但问题也很直接。标记阶段要暂停用户线程对象图越大暂停时间越长。清除阶段只是把对象占用的空间登记到“空闲列表”里空间还在但地址不连续。大量不连续碎片会让后续“分配一个大对象”时明明总空间够却找不出一块连续区域于是被迫提前触发GC。打个比方一间仓库里摆着很多大小不一的小纸箱你把小纸箱搬走后留下满地空隙现在要放一台大冰箱仓库的总面积够但每一块空地都不够塞。这就是老年代碎片化的尴尬。2.2 标记-复制新生代为什么敢用它标记-复制算法把内存分成大小相等的两块每次只用一块。GC时把存活对象复制到另一块然后把原区域整体清空。这样做的最大好处是分配简单、不会产生碎片最大代价是浪费了一半内存以及存活对象多时复制开销极大。JVM在新生代并没有真的对半分而是把空间分成一块较大的Eden和两块较小的Survivor默认比例8:1:1。新生代的对象绝大多数活不过第一轮GC所以复制成本很低用10%的空间作为“到了危难时刻才用”的逃生区整体非常划算。Minor GC的大致过程Eden和一块Survivor里有存活对象一次性复制到另一块空Survivor然后清空Eden和刚才那块Survivor。如果Survivor放不下多出来的对象就提前晋升到老年代。这里有一个长期容易被误解的点新生代不是“等Eden满了才GC”而是Eden满时触发Minor GCGC时把Eden和当前Survivor的存活对象一起处理。2.3 标记-整理老年代的搬家工老年代存活率高用复制算法成本太高标记-清除又会留下碎片于是有了第三种思路标记存活对象后不急着清除而是把它们往内存一端移动移动完之后统一清理边界以外的空间。整个过程像“搬家归拢”存活对象被紧凑排列碎片自然不存在。代价是移动对象时需要同步更新所有指向这些对象的引用这一步必须暂停用户线程否则一边搬一边有线程访问旧地址程序就崩了。这也是“标记-整理一定伴随较长的STW”的根本原因。2.4 算法与分代理论的组合逻辑把三种算法拼在一起完全是因为对象有很强的“分代”特征大部分对象朝生夕灭少数对象会活很久。于是JVM把堆分成新生代和老年代。新生代存活率低用标记-复制牺牲少量空间换取速度和零碎片。老年代存活率高用标记-清除或标记-整理避免大量复制带来的性能开销同时定期整理碎片。面试常见追问是“为什么老年代不用复制”。答案是老年代如果存活率高达九成复制九成对象还不如停下来统一整理划算而且复制需要保留一块足够大的空区在老年代这种大块头区域里空间成本高到无法接受。理解了这个“为什么”后面看CMS、G1和ZGC的设计动机都会顺很多。每一种收集器都是在这三个算法之间做组合和妥协。3. 从Serial到CMS垃圾收集器如何一步步摆脱长时间停顿3.1 早期收集器单线程串行与吞吐量优先最早的Serial收集器是新生代收集器单线程工作GC时必须暂停所有用户线程。它适合客户端应用或单核小内存场景参数简单行为也最好预测。ParNew是Serial的多线程版本同样只负责新生代经常作为老年代CMS的搭档出现。Parallel Scavenge则把目标直接放在“吞吐量”上追求“运行用户代码时间 / 总时间”尽量高适合后台批量计算这类对停顿不敏感、但对处理总量敏感的场景。那个年代最核心的矛盾是GC线程和用户线程要么完全串行要么只能在一个小区域内并行老年代的GC一到全程序还是得停下来。随着业务系统越做越大开发者开始无法接受动辄几秒的“世界暂停”CMS就是在这样的背景下诞生的。3.2 CMS的四个阶段CMSConcurrent Mark Sweep是第一款真正意义上瞄准“低停顿”的老年代收集器它采用标记-清除算法核心是把原本需要STW的步骤尽量拆散让GC线程和用户线程并发执行。初始标记只标记GC Roots能直接关联到的对象因为范围小STW时间很短。并发标记从直接关联对象出发遍历整个对象图这个阶段最长但GC线程和用户线程并发运行不阻塞业务。重新标记修正并发标记期间因用户线程继续运行而导致变动的引用关系需要短暂的STW。并发清除把垃圾对象占用的空间登记到空闲列表与用户线程并发进行。看起来问题解决了实际上CMS有三个长期被诟病的点一是并发阶段仍占用CPU对应用性能有影响二是基于标记-清除会产生碎片三是它无法处理“这次GC过程中新产生的垃圾”也就是浮动垃圾。3.3 CMS最危险的一环并发模式失败这里要展开说一个高频考点Concurrent Mode Failure。CMS的并发标记和并发清除都不是瞬间完成的这段时间用户线程还在跑老年代还会继续分配对象。为了给这段“并发期”留余地CMS必须在老年代使用率达到某个阈值默认值随JDK版本不同通常在70%~80%区间时提前开始GC。如果并发期新分配的对象太多老年代空间不够用了CMS只能退化成一种老路暂停所有用户线程用Serial Old垃圾收集器做一次Full GC。这就是面试里常说的“CMS并发模式失败”。结果就是停顿时间从几百毫秒变成几秒甚至十几秒低延迟目标彻底破产。解决思路也很直观调低触发阈值给并发阶段留更多空间但代价是GC更频繁。预留一部分空间比如-XX:CMSReservedCodeCacheSize之类的参数思路是一样的“提前为突发事件腾地方”。最终发现这些补丁都治标不治本因为碎片化和浮动垃圾仍然存在CMS自己也知道自己老了。3.4 CMS为什么注定被替代从算法上就能推出CMS的结局它为了并发性选择了标记-清除就必然要面对碎片化。碎片一多老年代即便总空间充足也无法分配连续的大对象只能提前再做一次Full GC。很多线上监控里“CMS后紧接着一次Full GC”的现象根因往往不是对象真的太多而是碎片化严重。要彻底解决必须换一个能像标记-整理那样搬动对象、却又不让用户线程长时间等待的机制。G1正是沿着这个方向走出来的。4. G1的Region化设计用“局部回收”换可控停顿4.1 分代还在但物理分代被拆了G1最直观的变化是堆不再分成连续的Eden、Survivor和Old区域而是切成许多大小相等的Region。每个Region可以动态扮演新生代或老年代的角色甚至还有一种专门存放超大对象的Humongous区域。这样一来G1没必要每次都全堆扫描。新生代GC只处理Eden和相关Region当老年代也被回收时是选择一批老年代Region做“Mixed GC”回收多少由用户设置的停顿目标决定。这种设计让“停顿时间”从“整个堆有多大”变成“这次挑了多少Region”可控性大幅提升。默认情况下G1在JDK 9之后成为HotSpot的默认收集器很多团队其实是带着“多线程版CMS”的误解去用的后面调试才发现它复杂得多。4.2 Remembered Set和Card Table跨代引用的账本如果GC只扫描一部分Region它怎么知道有别的Region指向了这些Region里的对象如果扫描所有Region那“局部回收”就失去了意义。解决方式是Remembered Set简称RSet。每个Region都维护一张表记录“有哪些外部Region引用了我的对象”。GC时只需要收集当前Region的RSet就能找到所有外部引用不需要全堆遍历。RSet的实现底层又依赖Card Table机制。JVM把堆空间分成一张张固定大小的卡片通常512字节对象写入引用时通过写屏障把对应的卡片标记为“脏”。当需要扫描RSet时只要看哪些卡片被弄脏了再到具体位置找引用即可。可以这么理解RSet是账本Card Table是账本里的索引目录。这套机制让G1可以做到真正意义上的“局部回收”但它也有成本——写屏障需要在每次引用赋值时更新卡片状态对吞吐量是有额外损耗的。4.3 三色标记与SATB并发安全的基石并发标记最大的风险是漏标用户线程正在跑已经把某个对象从“可达的引用路径”上摘掉了而GC线程还没标记到它或者GC线程已经认为某个对象标记完了又有新引用指向了另一个未标记对象如果没来得及更新对象就会被误判成垃圾。G1在三色标记的基础上引入了快照标记SATB。思路是记录并发标记开始时的对象图快照之后凡是新增引用都通过写屏障记录下来确保“在标记开始时是活着的对象这次GC都不会被漏掉”。这本质上是一种偏向保守的“宁可多留一点垃圾也不放过一个活对象”策略。这也是为什么G1并发标记后老年代里留的浮动垃圾会比CMS多——SATB本身就是带着空间余量去设计的。4.4 G1也会Full GC是的别神话它G1的停顿预测模型会根据历史统计估算这次收集哪些Region能在用户设定的停顿时间内完成但它毕竟是估算不是精确预测。如果发生大面积对象分配或者某些Region回收效果特别差G1仍然会退化成Full GC而Full GC在早期版本里是单线程执行的停顿时间非常难看。所以生产环境里用G1主要看两个动作合理设置-XX:MaxGCPauseMillis但不要设得过于激进比如10ms因为G1为了凑出这个目标可能频繁做年轻代GC产生大量无谓开销。关注晋升情况和Humongous对象。大对象如果特别多会占用大量连续Region影响回收效果。G1本质上是“把停顿控制在可接受的范围内”而不是“消除停顿”。如果追求极致低延迟就得把目光移到ZGC这类新一代收集器上。5. ZGC与更低停顿染色指针、读屏障和堆大小无关的STW5.1 ZGC的核心思路把“转移对象”也并发化ZGC出生于一个更极端的需求堆动辄几十GB甚至上百GB还要把GC停顿控制在毫秒级甚至更低。要实现这个目标不仅标记要并发连对象移动也要并发。难点在于对象移动后引用地址变了用户线程访问时怎么才能自动找到新地址。ZGC用一个很巧妙的手段染色指针。进程的地址空间里指针的高位被拿来存放标记信息对象是否已标记、是否已经转移都能直接从指针里读出来。当用户线程读对象时读屏障会检查指针状态如果发现对象已转移就顺着转发指针找到新位置再本地修正引用。这个过程不需要全局暂停去更新所有引用所以ZGC的STW时间可以保持在一个非常小的范围内而且基本不随堆大小增长。这也是ZGC能扛大堆低延迟业务的原因。5.2 从G1切换到ZGC要付出什么代价ZGC的并发能力强并不是免费的。读屏障让每次访问对象都多了一道检查CPU开销比G1更高染色指针也在压缩可用地址空间运行环境需要支持相应特性。另外高并发低停顿往往意味着吞吐量有所折损如果应用本身对吞吐量要求很高ZGC未必是最优选。从实践上说ZGC适合这类场景堆大、对响应时间要求高、业务可以接受一定的CPU开销。比如在线交易系统的内存缓存服务、大规模商品列表服务等。把堆设成几十GB目标停顿控制在个位数毫秒级别是ZGC的主场。JDK 11里ZGC还是实验特性到了JDK 15转正后续版本一直在做优化。如果你现在用的JDK还比较老想切ZGC先确认版本支持情况新项目直接跑在新版本上倒是不用太纠结。5.3 大堆低延迟场景收集器怎么选用一个简单的对照表来收拢思路。场景特征推荐收集器关键理由批量计算、后台任务对停顿不敏感Parallel吞吐量优先中等堆、延迟敏感但能容忍几十毫秒G1停顿可控成熟稳定超大堆、延迟极敏感能接受CPU开销ZGC停顿极短与堆大小相关性低老项目堆小团队对CMS参数熟CMS仅老版本JDK历史迁移期过渡使用选型时不要只盯“停顿越低越好”还要看运营成本。我见过有团队把所有服务无脑切到G1结果小堆服务GC频率比原来还高最终又调回Parallel的。收集器从来都是“按应用特征付费”没有绝对王者。6. 面试串讲与线上排查八股之外的几条硬道理6.1 线上Full GC变频繁排查路径应该是什么很多人在面试时能背出几十个参数但真到线上出问题就手忙脚乱。这里给一条我常用的排查路径面试中按这个思路答题通常比背结论更占优势。第一步先确认“频繁Full GC”是真实结论而不是Young GC次数变多被看错。用jstat可以看到分代GC次数和耗时先搞清楚是Minor GC多还是Full GC多。jstat -gcutil pid 1000第二步区分根因方向。是内存泄漏、对象分配过快、晋升阈值过低还是大对象过多、老年代碎片化严重这些方向对应的监控指标不同。第三步导出堆快照做对象分布分析。jcmd pid GC.heap_dump /tmp/heap.hprof拿到快照后重点看两个东西大对象列表和占内存最多的对象以及它们的引用链来自哪里。很多时候一眼就能看出来是某个静态缓存没有清或者某个连接池没有关闭。第四步结合GC日志看触发点。老年代使用率在GC前已经很高可能是并发模式失败GC前使用率不高却触发Full GC多半是碎片化如果是CMS时代检查碎片化非常常见。6.2 能说出“为什么”的才算真正掌握面试官真正想听的不是“CMS是老年代收集器”这种标签而是你能否从算法取舍里推出它的职业底色。比如问“为什么G1适合大堆CMS适合中小堆”可以这样回答CMS在并发标记和并发清除之外需要为浮动垃圾预留空间堆越大预留空间浪费越多G1通过Region和RSet实现了局部回收理论上可以在大堆上只挑部分Region回收从而稳定控制停顿。再比如问“为什么老年代用标记-整理比较合理”可以先承认标记-整理需要暂停用户线程再补一句“正因为它不用复制大量对象并且能消除碎片在存活率高的区域里整体开销更可控”这比直接背结论有深度得多。6.3 逃不过的最小知识清单如果时间不够至少把下面这些吃透再上考场。可达性分析GC Roots的几类来源。三种算法各自的优劣以及为什么分代组合它们。Minor GC与Full GC的触发源。CMS四个阶段名称以及每个阶段是否STW。CMS并发模式失败的原因。G1的Region、RSet、SATB、Mixed GC。ZGC的染色指针与读屏障。线上Full GC排查的第一步和第二步。这几个点覆盖了今年各路面试里最常见的追问方向。但更重要的是每一条都能连回到“算法怎么服务于停顿和吞吐量”这条主线上这样你就不是在背八股而是在用自己的话讲清这套系统的来龙去脉。最后分享一个我自己的笨办法。早些年我也是靠背题过的面试真正让我开窍的是线上一次RPC超时调用方反馈服务“卡死”了3秒多。查日志发现GC日志里有一段很长的Full GC当时我盯着日志想了很久才意识到之前背的“老年代存活率高所以用标记-整理”从来不是一句干巴巴的结论而是一场“我要暂停多久、能不能容忍”的实时取舍。从那以后我再也不把垃圾收集当概念背了。如果你也想真正掌握这块建议别急着刷题先按“对象怎么死→算法怎么选→收集器怎么组合→停顿从哪里来”这个顺序走一遍思路一旦通了面试题只是变着法子考这些同一件事。