☰
一次讲透Java垃圾收集:从GC Roots到CMS、G1、ZGC的设计取舍
2026/10/17 13:42:25 网站建设 项目流程

前几天有人拿着一份“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(); ReferenceQueue<Object> queue = new ReferenceQueue<>(); WeakReference<Object> 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 GC,GC时把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的四个阶段

CMS(Concurrent 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与更低停顿:染色指针、读屏障和堆大小无关的STW

5.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,当时我盯着日志想了很久,才意识到之前背的“老年代存活率高所以用标记-整理”从来不是一句干巴巴的结论,而是一场“我要暂停多久、能不能容忍”的实时取舍。从那以后,我再也不把垃圾收集当概念背了。如果你也想真正掌握这块,建议别急着刷题,先按“对象怎么死→算法怎么选→收集器怎么组合→停顿从哪里来”这个顺序走一遍,思路一旦通了,面试题只是变着法子考这些同一件事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询