垃圾回收器这题,答出"有 Serial、Parallel、CMS、G1"的人很多,答不出"它们各自在吞吐与停顿之间做了哪些取舍"的人占大多数。而面试官问的恰恰是取舍——因为不同收集器的差异,本质上就是"用吞吐换延迟"这条主线上不同的停靠点。理解这条主线,收集器就不再是一串需要背的名字。
先把结论放在前面:收集器的演进就是一条取舍线。 Serial 只有单线程,零额外开销,适合小堆;Parallel(也叫 Parallel Scavenge + Parallel Old)用多线程并行做标记与复制,吞吐高但停顿集中;CMS 引入并发标记,把大部分工作与应用线程并行、降低停顿,但会产生碎片且有浮动垃圾,已在 JDK 14 弃用;G1 把堆划分为等大小的 Region,按"回收收益排序"优先清理,从而能在给定停顿目标内尽量多回收;ZGC / Shenandoah 借助着色指针与读屏障实现亚毫秒停顿,代价是吞吐略低、可用堆规模受指针空间限制。
机制拆解
讲清 CMS 与 G1 之间的那次转折,这是这题的核心。CMS 的并发标记解决了"标记阶段长时间 STW"的问题,但它有三个残留问题:并发标记期间新对象是"浮动垃圾"(本轮不回收,等下一轮)、标记与清除之间会产生内存碎片、扫描线程与应用线程争抢 CPU。 而 G1 的解法很聪明:它不再要求"回收所有不可达对象",而是接受只回收一部分——把 Region 排成队列,每次挑几个脏 Region 复制回收,用"只清一部分但每一份都很快"换来可控的停顿。这就是 MaxGCPauseMillis 背后的设计哲学:暂停时间是目标,不是承诺。
这些坑的正确绕法
最常见的坑是小堆用 G1 的默认配置反而比 Parallel 更频繁并发周期,停顿更长。 G1 的优势建立在堆足够大、Region 足够多之上;堆小的时候 Region 数量少,每次并发周期要处理的比例反而更高,加上维护记账与写屏障的开销,实际表现可能不如 Parallel 这类专注吞吐的收集器。所以选型不是"新的就是好",而是看堆规模与延迟诉求的匹配度。
其次是追新版本收集器却忽略业务对象分配速率,GC 参数调不出预期效果。 停顿时间不只取决于收集器,还取决于每个周期要回收多少对象,而这由分配速率决定。应用如果每秒分配大量短命对象,收集器再先进也要频繁工作。有效的做法是同时看两个指标:分配速率(分配量/时间)与存活速率(回收后仍然存活的比例);后者高说明对象真的活得久,得从减少持有时长入手,前者高则可以考虑对象池、复用或分批处理。
还有一个更隐蔽的坑:把停顿时间当成唯一指标,忽略了吞吐。 ZGC 停顿很短,但单核性能开销明显,在低端机或需要高吞吐的服务端场景反而不如 G1;而 G1 为了达成停顿目标会做更多工作,CPU 消耗也高于 Parallel。评估要落到"单位时间内完成的业务量"这个综合指标上。
常见垃圾回收器的实践经验是:前提先声明,失败路径给兜底,事故挡在上线前。 落地成几条:先确认业务的延迟与吞吐目标再选收集器;不要动没有证据支持的参数;把 GC 日志与停顿分布纳入监控;Android 上还要考虑 ART 自己的收集器与设备内存等级,不能照搬服务端 JVM 的方案。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// 常见收集器:演进即"吞吐 ↔ 停顿"的取舍
// Serial:小堆,零额外开销
// Parallel(Scavenge+Old):多线程,吞吐高,停顿集中
// CMS:并发标记,但有浮动垃圾与碎片,JDK14 弃用
// G1:Region 化 + 收益排序,每次只回收一部分,目标是可控停顿
// ZGC/Shenandoah:着色指针 + 读屏障,亚毫秒停顿,吞吐略低
// 关键指标:MaxGCPauseMillis 是目标;分配速率决定每周期要收多少
这段代码值得盯两处:第一处,G1 的注释点明"每次只回收一部分"这个核心机制,它解释了 G1 的停顿控制原理;第二处,最后一行点出分配速率这个常被忽略的变量,提示参数调优的前提是先看业务。面试讲到这一层,基本就稳了。
这题在面试里怎么问、怎么答
"请简单介绍一下常见垃圾回收器,它在 Android 开发中起什么作用?"先给演进线(Serial → Parallel → CMS → G1 → ZGC),再点出每一代解决的核心问题(吞吐 → 停顿 → 碎片 → 可控停顿 → 亚毫秒)。Android 上补充一句:ART 使用自己的收集器(早期是 Concurrent Copying GC,近年引入其他策略),并受设备内存等级与堆配置影响。
"常见垃圾回收器的底层原理是什么?能不能详细说一下?"分两派答。新生代基本是复制算法:把存活对象复制到另一块 Survivor/Eden,天然无碎片、成本与存活量成正比;老年代则在标记清除、标记整理、标记复制之间选。G1 的独到之处是"标记—复制"在 Region 粒度上做,且回收顺序按垃圾量与区域大小估算收益。 ZGC 则把堆做成染色指针的页表式结构,用读屏障来判断对象是否被移动,从而不需要移动对象就能完成并发压缩。讲完这三派,这题可以给高分。
"在使用常见垃圾回收器时遇到过什么问题?"拿真实案例。一个典型案例:某次版本发布后 P99 延迟明显上升,GC 日志显示并发周期变频繁、每次要处理的对象变多;定位到某处新增的列表构建在主线程上反复产生大数组;修复为把构建移到后台并复用缓冲;验证是分配速率下降、停顿恢复到基线。这个案例讲清了"收集器只是执行者,分配速率才是输入"。
"和相关的替代方案相比,有什么优劣?"对比"换收集器"与"减少分配"两条线。结论:延迟问题先查分配速率与存活量,能不改收集器就不改;确实需要更低延迟再上 ZGC;追求吞吐极限的服务场景 Parallel 依然合适。选型时把"小堆用 G1 可能更差"这类代价摆到台面上,再决定是否引入。
再补一个工程上值得讲清的点:如何读 GC 日志来判断问题出在哪。停顿时间长,先看是老年代增长过快(说明存活量高、可能有泄漏)还是并发周期太频繁(说明分配速率高);如果是后者,缩短周期的手段是减少分配而不是换收集器;如果老年代在每次 Full GC 后都显著下降,说明不是泄漏,只是堆不够,可以评估调大;有曲线呈持续爬升且 Full GC 后也不降,才是泄漏的信号。 会读这几条曲线,面试里就能从"知道收集器"跨到"能定位问题"。
顺带说一个容易被问到的高频陷阱:finalize() 与 System.gc()。现代 JVM 已经废弃 finalize(),依赖它释放资源是典型的过时做法,应该用 try-with-resources 或显式的 close();而 System.gc() 只是向 GC 发出一个"建议",实现上完全可以忽略,依赖它回收会得到不确定的行为。 面试里能主动纠正这两个过时做法,通常会明显加分。
给正在准备面试的你
把收集器画成一条时间轴:Serial → Parallel → CMS → G1 → ZGC,每个节点标出"它解决了上一代什么问题"与"付出了什么代价",然后在轴下方画出两条曲线——停顿时间下降、吞吐下降。再在图上标出今天 JVM 的默认选择与 Android 侧 ART 的差异。面试中关于收集器的问题,关键在于能够从原理、应用、踩坑三个层面给出有深度的回答。
再补工程案例与踩坑——应用落点是抓一次线上 GC 日志,画出停顿分布与老年代占用曲线,据此判断是分配速率问题还是泄漏问题,并给出对应的验证与修复方案。
复习时别孤立刷题:垃圾回收基础——收集器解决的是"怎么回收",判定标准与引用分级仍是上一环。
划两句重点:收集器的演进是吞吐与停顿之间的取舍,G1 靠"只回收一部分脏 Region"达成停顿目标;停顿时间不只取决于收集器,更取决于分配速率与存活量,选型前先量这两个指标。
下一篇聊 JVM 调优入门:从 OOM 日志倒推问题——沿着今天这条主线继续往前走。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:垃圾回收基础:可达性分析与-GC-Roots
下一篇预告:JVM-调优入门:从-OOM-日志倒推问题
有任何问题欢迎在评论区留言交流。