☰
25 面试官让你讲 GC 收集器演进,其实是想听你怎么解决“对象消失“
2026/10/11 1:46:15 网站建设 项目流程

面试现场,今天的候选人简历上写着"精通 JVM"。

面试官:"GC 收集器,你说说你了解哪几个。"

候选人A:"Serial、ParNew、Parallel、CMS、G1,还有……ZGC 吧。现在用的多是 G1。"

面试官:"那你按演进顺序,讲讲每个收集器的定位,为什么会有下一个替代它。"

候选人A:"Serial 是单线程的,慢;ParNew 是多线程的;Parallel 是多线程吞吐量高;CMS 是并发低延迟;G1 是分区的,也能控制停顿……具体区别就这些。"

面试官点点头,话锋一转:"那我问个偏底层的问题——并发标记的时候,用户线程在跑,对象引用在变,你凭什么保证不会漏标一个还活着的对象?"

候选人A:"这个……标记完应该是准的吧?"——明显没想过这个问题。

候选人B接过了话筒:"漏标是并发标记绕不开的问题,叫'对象消失'。它要同时满足两个条件:黑色对象新增了指向白色对象的引用,同时灰色对象到那个白色对象的引用被全删了。解决办法有两种——CMS 用增量更新,破坏第一个条件;G1 用原始快照 SATB,破坏第二个条件。这两个都得靠写屏障把变更记下来,等到重新标记阶段再处理。"

面试官靠到了椅背上。

今天这篇,就把收集器的演进史,和三色标记里最难的那块——漏标,一次讲清楚。


一、先把三种基本算法摆上桌

讲收集器之前,得先知道它们背后就那么三种算法在打转。

标记-清除(Mark-Sweep):先标记出所有要回收的对象,标记完了统一清除。缺点是会产生大量不连续的内存碎片,以后要分配一个大对象,明明总空闲内存够,却因为找不到连续的块而再触发一次 GC。

标记-复制(Mark-Copy):把内存分成两块,每次只用一块,用满就把活着的对象整块复制到另一半,然后把这一半清空。没有碎片,分配快,但代价是可用内存只剩一半。所以它一般用在对对象生命周期短的新生代上——新生代里绝大多数对象朝生夕死,复制过去的很少,很划算。

标记-整理(Mark-Compact):标记完之后,把所有存活对象往一端挪,再清理边界外的内存。没有碎片,也不用折损一半空间,代价是移动对象要改所有引用,开销大。

记住一句话:新生代用复制,老年代用清除或整理。收集器的演进,本质就是这几个算法的排列组合,加上"能不能让用户线程少停一会儿"的追求。


二、收集器的演进:从"能干活"到"少停顿"

Serial / Serial Old:最基础的收集器,单线程。工作的时候必须"Stop The World"——把用户线程全停下来,GC 干完再恢复。新生代用 Serial(复制),老年代用 Serial Old(标记-整理)。它简单、省内存,到现在也没淘汰,只是在客户端模式或者小内存场景里还能见到。缺点是单线程,堆一大停顿就长。

ParNew:就是 Serial 的多线程版本,新生代并行收集,其他没变。它的名字总和 CMS 绑在一起,因为它是当年 CMS 默认搭配的新生代收集器。

Parallel Scavenge / Parallel Old:这对兄弟的目标和前面几个不一样。Serial、ParNew 关心的是"停顿尽量短",而 Parallel 关心的是吞吐量。什么叫吞吐量?就是运行用户代码的时间 /(运行用户代码时间 + 垃圾收集时间)。它默认给用户代码留 99% 的时间,最多允许 1% 花在 GC 上,这个比例由-XX:GCTimeRatio控制。Parallel 还能用-XX:MaxGCPauseMillis设最大停顿,配合-XX:+UseAdaptiveSizePolicy,让 JVM 自己根据你设的目标去调新生代大小、Eden 和 Survivor 的比例。适合那种"后台跑批、交互不敏感"的场景。JDK 8 默认用的就是它。

讲到这,一个核心取舍就浮出来了:吞吐量和低延迟,往往不可兼得。多开 GC 线程能提升吞吐,但 CPU 就那么多;GC 干得越勤,停顿就越碎,但总时间未必降。前端响应型的服务偏延迟,后台计算型的偏吞吐。

CMS(Concurrent Mark Sweep):这是第一个真正意义上的并发收集器,目标很纯粹——尽可能缩短停顿。它只负责老年代,用标记-清除算法。它的收集过程分四步:

1. 初始标记(CMS initial mark)——Stop The World,但只标记 GC Roots 直接关联的对象,很快

2. 并发标记(CMS concurrent mark)——和用户线程并发跑,做真正耗时的可达性分析

3. 重新标记(CMS remark)——Stop The World,修正并发标记期间因为用户线程继续跑导致的变动,速度比初始标记慢,但比并发标记快

4. 并发清除(CMS concurrent sweep)——和用户线程并发清理

为什么它能收停顿?因为最耗时的"并发标记"和"并发清除"都不暂停用户线程,停顿只集中在两个很短的标记阶段。

但 CMS 的毛病也很明显,面试常问:

-内存碎片:标记-清除不整理,碎片攒多了,大对象分配不下,就会提前触发 Full GC。参数-XX:+UseCMSCompactAtFullCollection可以在 Full GC 时整理,但整理过程又要 STW。

-并发模式失败(Concurrent Mode Failure):并发清除时,用户线程还在产生新对象,老年代空间顶不住。一旦发生,CMS 会退化成 Serial Old 单线程 Full GC,停顿直接爆表。缓解办法是把触发阈值调低——-XX:CMSInitiatingOccupancyFraction,让老年代用到某比例(默认约 68%)就开始收集,给并发阶段留余地。

-浮动垃圾:并发标记期间,用户线程把某些对象变成垃圾,这些垃圾这一轮收不掉,只能等下一轮。这叫"浮动垃圾",可以容忍。

-对 CPU 敏感:并发阶段虽然不暂停用户线程,但抢 CPU。GC 线程数和核心数挂钩,CPU 少的时候,用户线程吞吐明显掉。

CMS 在 JDK 9 之后被标记为废弃,JDK 14 正式移除。原因就是它那些治不好的老毛病——碎片和并发失败,太难调。

G1(Garbage First):继承者,JDK 7 引入,JDK 9 起成为默认。

G1 的思路和前面都不一样。它不再把堆物理地分成"新生代一大块、老年代一大块",而是把整个堆切成一个个大小相等的Region(分区),Region 的大小在 1MB 到 32MB 之间,通常是 2 的幂。每个 Region 在逻辑上可以扮演 Eden、Survivor、Old 甚至Humongous(存放超大对象)的角色,角色是动态的。

这样做的好处是什么?它可以"挑着收"。G1 会在后台维护每个 Region 里垃圾占的比例,回收时优先挑那些"垃圾最多、收益最大"的 Region 下手——这也是它名字"Garbage First"的由来。

G1 的收集模式主要有三种:

-Young GC:回收所有新生代的 Region

-Mixed GC:回收全部新生代 Region,加上一部分老年代 Region(根据收益挑选)

-Full GC:上面两种都兜不住时的退化方案,会 STW

G1 的停顿可预测,靠的是-XX:MaxGCPauseMillis(默认 200ms)。它会根据历史数据(衰减平均值)去估算每个 Region 的回收耗时,然后挑一批能在你设定的停顿目标内收完的 Region。所以它不是硬保证,是一个尽力而为的模型——这也解释了为什么 G1 有时还是会有长暂停。

算法上,G1 从整体看是标记-整理(Region 之间不产生碎片),从两个 Region 之间看是复制(把存活对象复制到空的 Region)。而且 G1 解决漏标用的是和 CMS 完全不同的办法——原始快照,这个下一节细说。

ZGC:再往后就是追求极致低延迟的 ZGC 了。它的官方设计目标是停顿时间不超过 10 毫秒,而且不随堆大小增长——堆再大,停顿也就那么点。它靠两个核心技术:着色指针(把标记信息直接编进指针的二进制位里)和读屏障(在读对象引用的时候做手脚,把一部分 GC 工作分摊到应用线程的每一次读操作上)。全部标记、转移、重定位几乎都是并发的,所以能扛住 TB 级的堆。最早的 ZGC 不分代,后来分代 ZGC 也被提上日程。它和 Shenandoah 属于同一档——把延迟压到极致,代价是吞吐会掉一些。

一张表把这几个的定位串一下:

收集器区域算法目标
Serial新生代复制简单、单线程
ParNew新生代复制多线程版 Serial
Parallel Scavenge新生代复制高吞吐
Parallel Old老年代标记-整理配合 Parallel
CMS老年代标记-清除低延迟(已废弃)
G1整堆(Region)整体整理/局部复制可预测停顿
ZGC整堆(并发)标记-复制(着色指针)极低延迟、超大堆

三、三色标记:并发标记里那块最难的骨头

为什么单独拎出三色标记?因为前面所有"并发"收集器都有一个共同的死结:标记的时候,用户线程还在改对象引用。改着改着,就可能把一个本该活着的对象,标漏成垃圾——一旦漏标,GC 就会把它回收掉,你的程序莫名其妙地 NPE,而且几乎无法定位。这个错误,学术上叫对象消失。

先把三色标记讲清楚。

三色标记把对象分成三种颜色:

-白色:GC 还没访问到它。如果标记结束它还是白的,就说明它不可达,可以回收。

-灰色:GC 访问到它了,但它引用的对象还没扫描完。灰色是"正在处理"的中间态。

-黑色:GC 访问过它,而且它引用的所有对象也扫描完了。

标记的过程,就是灰色的集合不断扩散:从 GC Roots 开始,把直接引用染灰,扫描一个灰色对象,就把它引用的白色对象染灰,自己变黑。等灰色集合空了,剩下的白色就是垃圾。

问题来了——如果这个过程和用户线程并发,用户线程一边跑一边改引用,就可能在标记的同时改写图结构。


四、漏标要同时满足两个条件,怎么破?

先说清楚"对象消失"发生的必要条件。它要同时满足两条:

条件一:赋值器(用户线程,规范里叫 Mutator)插入了一条或多条从黑色对象到白色对象的新引用。

条件二:赋值器删除了全部从灰色对象到那个白色对象的直接或间接引用。

为什么这两条同时满足才会出事?你想:一个白色对象,本来有一条路径能到它(穿过灰色对象)。如果灰色→白色的这条链被删了(条件二),那按常规的扫描逻辑,白色对象就不可达了,应该被当垃圾。但如果这时候有个黑色对象新建了一条指向它的引用(条件一),黑白之间就"复活"了它——可黑色对象已经被标记为"扫描完毕",GC 不会再去扫它,所以这条新引用永远不会被发现。结果就是:活着的对象被漏标,下一轮被回收掉。

那怎么破?两条路,各破坏一个条件。

增量更新(Incremental Update)——破坏条件一。

既然问题出在"黑色对象新增了指向白色的引用",那我就在黑色对象插入新引用的时候,把这个黑色对象重新记录成灰色,等重新标记阶段再扫一遍它。这样就确保那条新引用不会逃过检查。

原始快照(Snapshot At The Beginning,SATB)——破坏条件二。

它换个思路:既然问题出在"灰色→白色的引用被删了",那就在删除这个引用的时候,把被删掉的那个白色对象记录下来,当成"快照时它还是活的"来对待,最终标成存活。

这两种方案都要靠一个东西落地——写屏障(Write Barrier)。写屏障不是内存屏障,它是 JVM 在"给某个引用字段赋值"这个动作前后,插入的一小段代码。增量更新用的是后写屏障(赋值之后记录),SATB 用的是前置写屏障(赋值之前,先把老值记下来)。记录的这些对象,进一个队列,等到最终的重新标记(Remark)阶段统一处理。

两大收集器的对应关系,记住就行:

-CMS 用增量更新——重新标记阶段会重新扫描那些在并发标记中被改动过的对象。

-G1 用原始快照——靠 SATB 队列记录被删的引用,把它们当成活的。

顺带提一个配套概念:并发标记里,跨代的引用(老年代对象引用新生代对象)如果漏了,也会漏标。所以收集器还得维护一个记忆集(Remembered Set),G1 里每个 Region 都有一个 RSet,记录"谁引用了我";底层实现常用卡表(Card Table)——把内存按固定大小划成一张张卡,某张卡里有引用变动就把这张卡标记为脏,重新标记时只扫脏卡。这些机制合在一起,才能保证并发标记不漏。


🎯 面试官真正想听的答案

问他收集器演进,他想听的不是干巴巴的列表,而是三个层次:每个收集器为什么出现、取舍是什么、以及并发标记怎么保证正确。

第一层,给结论:"GC 收集器是按'停顿 vs 吞吐'这条主线演进的。Serial 单线程最简单;ParNew 是它的多线程版;Parallel 转向追求吞吐;CMS 第一次实现并发收集、主打低延迟,但受限于标记-清除的碎片和并发失败;G1 用 Region 化把堆拆开、优先回收垃圾最多的区域,实现可预测停顿;ZGC 用着色指针和读屏障,把停顿压到十毫秒以内且不随堆增长。新生代用复制算法,老年代用清除或整理,这是所有收集器的底层公式。"

第二层,把取舍讲透:吞吐量和延迟经常打架;CMS 的四个痛点——浮动垃圾、内存碎片、并发模式失败、对 CPU 敏感——正是它被 G1 取代的原因;G1 的停顿目标是个"模型"不是"保证",所以仍会有长暂停。

第三层,把并发标记的正确性说清楚:这才是拉开差距的地方。三色标记里白色是垃圾、灰色是待扫描、黑色是已扫完;并发时漏标叫"对象消失",必须同时满足"黑色新增指向白色的引用"和"灰色到白色的引用被全删";CMS 用增量更新破坏前者(把黑重新记灰),G1 用原始快照破坏后者(记录被删的白),两者都靠写屏障落地,配合记忆集/卡表处理跨代引用。能把这套讲明白,面试官基本认定你是真的看过 GC 的底层。


下篇预告

下一篇聊实战:OOM 排查。理论讲了一堆,真到线上抛OutOfMemoryError的时候,你得会看现场。堆溢出、元空间溢出、栈溢出、直接内存溢出,四种 OOM 的现场特征完全不一样,排查手法也不一样。

会讲几个真正常用的手段:怎么用jmap把堆 dump 出来、用 MAT 找"支配树"里的大头、用jstat看 GC 频率、用jstack看线程、还有怎么区分"内存泄漏"和"内存不够用"。这一篇偏动手,配流程图,照着走就能排。

想先预习的话,把jmap -help、jstat -help看一眼,把参数列出来对一遍。


唠点键盘之外的 · 第 25 篇

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

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

立即咨询