第048篇 常见垃圾回收器:从 CMS 到 G1 的演进思路

简介: 本文深入剖析JVM垃圾回收器的演进本质——并非技术堆砌,而是“吞吐↔停顿”的持续取舍:Serial(小堆低开销)、Parallel(高吞吐/长停顿)、CMS(并发降停顿但碎片/弃用)、G1(Region收益优先/可控停顿)、ZGC/Shenandoah(亚毫秒停顿/吞吐略损)。强调选型需匹配堆规模与业务指标,核心在理解分配速率、存活率与停顿目标的联动关系。

垃圾回收器这题,答出"有 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-日志倒推问题

有任何问题欢迎在评论区留言交流。

相关文章
|
13小时前
|
缓存 测试技术 API
第043篇 CAS 与原子类:无锁编程的第一课
CAS是Compare-And-Swap的缩写,一种硬件级原子指令,实现“比较并交换”的乐观并发控制。它通过volatile保证可见性、CAS保证原子性,但存在ABA问题(用AtomicStampedReference加版本号解决)、高竞争下自旋耗CPU(LongAdder分段累加优化)及仅支持单变量更新等局限。
17 0
|
13小时前
|
Java 编译器 API
第053篇 Lambda 与函数式接口:Android 开发的日常语法
本文深入剖析Java Lambda与函数式接口的本质:从匿名内部类演进而来,依托`invokedynamic`与`LambdaMetafactory`实现运行时链接;详解四大核心接口(`Function`/`Consumer`/`Supplier`/`Predicate`)、捕获语义、泛型擦除及Android兼容坑点,强调“行为参数化”思想而非语法糖。
23 0
|
13小时前
|
安全 Java 编译器
第068篇 sealed class:状态建模的利器
`sealed class` 是 Kotlin 状态建模的核心工具,本质是“用类型系统表达封闭状态机”。它强制子类同文件/模块定义,使编译器可穷举所有分支,配合 `when` 实现**无 `else` 的完备匹配与类型安全**,彻底杜绝非法状态(如 `loading && data != null`),远超枚举与多字段布尔组合的表达力。
19 0
|
15小时前
|
缓存 安全 Java
第020篇 == 与 equals 的区别:从栈堆内存说起
面试官问“== 与 equals 区别”,真正考察的是完整心智模型:基本类型==比值,引用类型==比地址;equals默认等价==,重写后比逻辑内容,且**必须同步重写hashCode**。常见坑包括Integer缓存、字符串常量池、HashMap去重失效、枚举误用equals等。工程建议:值对象一律用Objects.equals,包装类/字符串禁用==,枚举用==更安全。
15 0
|
15小时前
|
安全 Java 编译器
第024篇 泛型基础:类型擦除到底擦了什么
本文深入剖析Java泛型本质:以“问题—擦除—运行时残留—错误表现”四步公式讲清原理;透彻解析类型擦除机制、桥接方法及常见坑(raw type、new T[]、instanceof泛型);结合可运行代码与Android实战场景,助你面试答出“为什么”,而非仅“怎么写”。
19 0
|
13小时前
|
SQL 缓存 安全
第058篇 单例模式六种写法:线程安全与懒加载的平衡
单例模式是面试高频考点,六种写法各具特点:饿汉式线程安全但非懒加载;懒汉式需同步;DCL需`volatile`防重排;静态内部类最推荐(JVM类初始化锁保障懒加载与线程安全);枚举天然防反射与序列化。Android中优先用静态内部类,持Context时务必用`getApplicationContext()`,避免内存泄漏。
24 0
|
14小时前
|
安全 Java API
第027篇 ArrayList 源码与扩容机制:1.5 倍增长的细节
ArrayList面试高频题:JDK8中无参构造不分配数组,首次add才扩容至10;后续按1.5倍增长,不足时直取所需容量,上限为Integer.MAX_VALUE-8。扩容本质是Arrays.copyOf整段拷贝。关键要懂原理、避坑(如预估容量、非线程安全、subList陷阱)并结合工程实践。
19 0
|
13小时前
|
SQL 安全 Java
第073篇 字符串模板与原生字符串:多行文本的正确姿势
Kotlin字符串模板核心三点:①编译后转为`StringBuilder.append`链(全常量时优化为字面量);②原生字符串`"""..."""`保留缩进,必须用`trimIndent()`或`trimMargin()`清理;③Android中禁在循环内用模板拼接,否则触发O(n²)性能坑。安全上,模板不用于SQL/URL等需解析的场景。
23 0
|
13小时前
|
缓存 Java 数据库
第078篇 Sequence 与惰性求值:大数据量集合优化
`Sequence` 的核心是**惰性求值**(操作延迟至终端才执行)与**冷流语义**(每次遍历都重新计算,不缓存)。它省内存(无中间集合),但不支持多次遍历、随机访问;适用于大集合多级过滤或无限序列截断(如 `take`)。慎用于副作用操作、未截断的无限流及需重复消费场景——应物化(`toList()`)或内联使用。
28 0
|
14小时前
|
缓存 安全 Java
第031篇 ConcurrentHashMap:从分段锁到 CAS 加 synchronized
ConcurrentHashMap(JDK8)通过CAS初始化空桶、桶头节点加synchronized锁实现细粒度并发写,get无锁依赖volatile可见性;size采用baseCount+CounterCell分片计数;禁止null键值,复合操作须用compute/merge等原子方法——兼顾高性能与线程安全。
16 0