Lesson 35 · JVM 原理与调优
垃圾收集器全览:Serial → Parallel → CMS → G1 → ZGC
面试官:你用过哪些垃圾收集器?它们有什么区别?
"我们线上用的 G1。" 大部分候选人只能说到这一句。如果你能讲清楚五代收集器的演进脉络、每一代要解决的核心痛点、以及它们各自的工程取舍——这道题就是全场最佳回答。
垃圾收集器是 JVM 面试中区分度极高的话题。初级工程师知道"有 GC",中级工程师知道"G1 是默认",而高级工程师能讲清楚为什么会有五代收集器,每一代在吞吐量、延迟、内存 footprint 三者之间做了怎样的取舍。
JDK 的垃圾收集器演进史就是一部"如何在停顿时间与吞吐量之间寻找最优解"的工程史:
| 收集器 | 登场时间 | 核心目标 | 一句话特征 |
|---|---|---|---|
| Serial | JDK 1.0 | 简单、无额外开销 | 单线程,全程 STW |
| Parallel | JDK 1.4 | 最大化吞吐量 | 多线程 STW,JDK 8 默认 |
| CMS | JDK 1.5 | 低停顿时间 | 并发标记-清除,JDK 14 移除 |
| G1 | JDK 7 | 可预测停顿 | Region 化堆,Mixed GC,JDK 9+ 默认 |
| ZGC | JDK 11 | 亚毫秒停顿 | 染色指针 + 读屏障,JDK 15 生产可用 |
不要只说"G1 好用"。能说出"CMS 在 JDK 14 被移除,因为它的浮动垃圾和并发模式失败问题在 G1 的 Region 架构下被根本性地解决了"——这才是有深度的回答。本文会帮你建立这种全局视角。
Serial GC:最朴素的单线程回收
Serial GC 是 JVM 最古老的垃圾收集器,也是最简单的一个。它的核心思想只有一句话:用一个线程来完成所有垃圾回收工作,回收期间暂停所有用户线程(Stop-The-World)。
单线程做 GC 听起来很低效,为什么它至今仍然存在?
因为"简单"本身就是一种优势。没有多线程切换开销、没有同步屏障、没有内存碎片整理——在堆很小的场景下(比如客户端应用、嵌入式 JVM),Serial GC 反而是效率最高的选择。
工作流程
Serial GC 对新生代和老年代分别使用不同的算法:
- 新生代(Young Gen):使用 复制算法(Copying)。将 Eden 和 From Survivor 中的存活对象复制到 To Survivor,然后清空 Eden 和 From。这个过程称为 Minor GC。
- 老年代(Old Gen):使用 标记-整理算法(Mark-Compact)。先标记所有存活对象,然后将它们向一端移动压缩,最后清理边界之外的空间。这个过程称为 Major GC / Full GC。
# 客户端模式(Client VM)默认启用 java -XX:+UseSerialGC -jar app.jar # 典型适用场景 # - 堆大小 < 100MB 的小型应用 # - 单核 CPU 环境(如容器内 cpu=1) # - GraalVM Native Image 默认 GC
Serial GC 的优劣势
| 维度 | 表现 | 原因 |
|---|---|---|
| 停顿时间 | 差(毫秒~秒级) | 全程 STW,单线程处理 |
| 吞吐量 | 小堆下尚可 | 没有多线程开销 |
| 内存开销 | 极低 | 不需要额外的数据结构 |
| 适用场景 | 小堆、单核 | 简单即是美 |
Serial GC 是单线程全程 STW 的收集器,没有多线程同步开销,适合小堆和单核环境。在容器化场景下,如果容器只分配了 1 个 CPU 核心,Serial GC 反而可能比 Parallel GC 表现更好,因为避免了线程上下文切换。
Parallel GC:吞吐量之王
Parallel GC(也叫 Throughput Collector)是 JDK 8 的默认垃圾收集器。它的设计目标非常明确:最大化应用程序的吞吐量。做法也很直接——把 Serial GC 的单线程回收改成多线程并行回收。
Parallel 和 Serial 的核心区别是什么?停顿时间会减少吗?
Parallel GC 的多线程回收确实减少了每次 GC 的停顿墙钟时间(wall-clock time),但所有用户线程仍然被暂停(STW)。它的目标不是减少停顿,而是在给定 CPU 资源下最大化应用吞吐量。如果你关心的是"单位时间内处理更多请求",Parallel GC 是对的选择。
新生代:Parallel Scavenge(并行复制)
新生代使用 Mark-Copy 算法,多线程并行完成:
- 多线程并行扫描根集合(GC Roots),标记 Eden 和 From Survivor 中的存活对象
- 多线程并行将存活对象复制到 To Survivor
- 清空 Eden 和 From Survivor
Parallel Scavenge 收集器有一个独特特性:它关注的是吞吐量而非停顿时间,因此提供了 -XX:MaxGCPauseMillis(最大停顿目标)和 -XX:GCTimeRatio(GC 时间占比目标)两个自适应调节参数。
老年代:Parallel Old(并行标记-整理)
老年代使用 Parallel Mark-Compact:多线程并行标记存活对象,然后多线程并行压缩整理。这比 CMS 的标记-清除算法有更好的空间利用率(无碎片),但停顿时间更长。
# 启用 Parallel GC(JDK 8 默认) java -XX:+UseParallelGC -XX:+UseParallelOldGC \ -XX:ParallelGCThreads=8 # GC 线程数,默认=CPU 核心数 -XX:MaxGCPauseMillis=200 # 目标最大停顿时间 -XX:GCTimeRatio=19 # 目标 GC 时间占比 1/(1+19) = 5% -jar app.jar
Parallel GC 的 GCTimeRatio=19 意味着目标吞吐量 = 1/(1+19) = 95%
Parallel GC 是 JDK 8 的默认收集器,通过多线程并行回收来最大化吞吐量。新生代用并行 Mark-Copy,老年代用并行 Mark-Compact。它的 STW 时间虽然比 Serial 短(因为多线程),但仍然是全程暂停用户线程的。适合后台计算、批处理等吞吐量敏感场景,不太适合 Web 服务等延迟敏感场景。
CMS:并发标记清除的先驱与困境
CMS(Concurrent Mark-Sweep)是 JDK 1.5 引入的低延迟收集器,在 JDK 5~8 时代是 Web 应用的首选。它的设计目标与 Parallel GC 完全相反:最小化停顿时间。CMS 通过将大部分回收工作与用户线程并发执行来实现这一目标。
为什么"并发"比"并行"更难?
并行是多线程同时做同一件事(比如多线程一起标记),线程之间做的是同一份工作的不同部分。并发是 GC 线程和用户线程同时运行——用户线程在 GC 标记的同时还在修改对象引用关系,这就产生了"我标记了 A 引用 B,但用户线程把 A 改成了引用 C"的一致性问题。CMS 的许多复杂机制都是为了处理这个并发不一致问题。
CMS 的四个阶段
CMS 的老年代回收分为四个阶段,其中两个需要 STW,两个与用户线程并发:
| 阶段 | 类型 | 做了什么 | 停顿? |
|---|---|---|---|
| 1. 初始标记(Initial Mark) | STW | 标记 GC Roots 直接引用的对象 | 是(很短) |
| 2. 并发标记(Concurrent Mark) | 并发 | 从 GC Roots 出发遍历整个对象图 | 否 |
| 3. 并发预清理(Concurrent Preclean) | 并发 | 处理并发标记期间发生变化的引用 | 否 |
| 4. 最终标记(Final Remark) | STW | 修正并发阶段因用户线程运行而变动的标记 | 是(较长) |
| 5. 并发清除(Concurrent Sweep) | 并发 | 清除标记为垃圾的对象 | 否 |
CMS 的三大问题
问题一:浮动垃圾(Floating Garbage)
在并发清除阶段,用户线程仍在运行,会产生新的垃圾。这些垃圾只能在下一次 GC 时才能被回收。这意味着 CMS 不能等到堆快满了才启动 GC,需要提前触发——通常当老年代使用率达到 68%(-XX:CMSInitiatingOccupancyFraction)时就开始回收。
问题二:并发模式失败(Concurrent Mode Failure)
如果 CMS 回收的速度赶不上对象分配的速度,老年代空间耗尽,CMS 就会退化为一次 Full GC(Serial Old 单线程标记-整理),停顿时间暴涨到秒级。这是 CMS 最让人头疼的问题。
问题三:内存碎片
CMS 使用标记-清除算法,不会压缩整理老年代,因此会产生大量内存碎片。碎片化严重时,即使总空闲内存足够,也可能因为找不到连续空间来分配大对象而触发 Full GC。
# JDK 8 中启用 CMS java -XX:+UseConcMarkSweepGC \ -XX:CMSInitiatingOccupancyFraction=68 # 老年代占用 68% 时触发 -XX:+UseCMSInitiatingOccupancyOnly # 只在达到阈值时触发 -XX:+CMSScavengeBeforeRemark # Remark 前先做一次 Minor GC -jar app.jar # JDK 9: -XX:+UseConcMarkSweepGC 产生警告 # JDK 14: 彻底移除 CMS,使用该参数会报错
Q: CMS 为什么被移除?
三个原因:① 浮动垃圾导致空间利用率低;② 并发模式失败会退化为 Serial Old 单线程 Full GC,停顿不可预测;③ 标记-清除产生内存碎片。这些问题在 G1 的 Region 化架构下得到了根本性解决——G1 使用复制算法天然无碎片,Mixed GC 可以增量回收老年代。
CMS 是第一个尝试将 GC 工作与用户线程并发执行的收集器,目标是低停顿。它有四个阶段:初始标记(STW)→ 并发标记 → 并发预清理 → 最终标记(STW)→ 并发清除。但有三个致命问题:浮动垃圾(并发清除期间产生的新垃圾)、并发模式失败(回收速度跟不上分配速度,退化为 Full GC)、内存碎片(标记-清除不压缩)。这些问题促使了 G1 的诞生,CMS 在 JDK 14 被正式移除。
G1:Region 化堆与 Mixed GC
G1(Garbage First)是 JDK 9 以来的默认垃圾收集器。它从架构层面颠覆了前面几代收集器的设计思路:不再将堆划分为固定的新生代和老年代,而是将整个堆划分为大小相等的 Region。每个 Region 可以动态扮演 Eden、Survivor、Old 或 Humongous 角色。
为什么要把堆切成一块一块的 Region?
CMS 最大的问题是 Full GC 停顿不可控——当并发模式失败时,要对整个老年代做单线程标记-整理。G1 的核心洞察是:如果堆被切成独立的 Region,GC 时可以只选择收益最高的 Region(Garbage First 名字由来)进行回收,而不是每次都处理整个堆。这让停顿时间变得可预测。
Region 架构
G1 将堆划分为约 2048 个大小相等的 Region,每个 Region 大小在 1MB~32MB 之间(必须是 2 的幂次方),由堆大小自动决定:
G1 的回收过程
G1 的回收分为两种类型:
Young GC:回收所有 Eden 和 Survivor Region。使用多线程并行复制存活对象到新的 Region(本质上是并行复制算法)。
Mixed GC:在 Young GC 的基础上,额外选择部分老年代 Region 一起回收。G1 通过并发标记周期来确定哪些老年代 Region 的垃圾比例最高(Garbage First),然后选择收益最高的 Region 进行回收。
并发标记周期
G1 的并发标记与 CMS 类似,但更简洁,因为 G1 使用了 Snapshot-At-The-Beginning(SATB) 算法:
- 初始标记(STW):标记 GC Roots 直接引用的对象,搭载在 Young GC 的 STW 阶段
- 并发标记:与用户线程并发遍历对象图
- 最终标记(STW):处理 SATB 队列中的引用变更记录
- 筛选回收(STW):计算每个 Region 的回收价值(垃圾比例),选择性价比最高的 Region 组成 Collection Set
Remembered Set(RSet)
因为 Region 的角色是动态的,G1 无法像分代收集器那样只扫描新生代。为了解决跨 Region 引用的问题,每个 Region 都维护一个 RSet,记录"谁引用了我"。这样在 Young GC 时,只需扫描年轻代 Region 的 RSet 就能找到所有引用关系,而不需要扫描整个老年代。
RSet 的代价:每个 Region 的 RSet 通常占用 Region 大小的 5%~20%,这是 G1 内存开销比 Parallel GC 更高的主要原因之一。
java -XX:+UseG1GC \ -XX:MaxGCPauseMillis=200 # 目标最大停顿时间(默认 200ms) -XX:G1HeapRegionSize=4m # Region 大小(1m/2m/4m/.../32m) -XX:InitiatingHeapOccupancyPercent=45 # 触发并发标记的堆占用率 -XX:G1NewSizePercent=20 # 新生代最小占比(默认 5%) -XX:G1MaxNewSizePercent=60 # 新生代最大占比(默认 60%) -XX:ConcGCThreads=2 # 并发标记线程数 -XX:G1ReservePercent=10 # 保留空间防 evacuation failure -jar app.jar
Evacuation Failure(疏散失败)
类似 CMS 的并发模式失败,G1 在 Mixed GC 期间如果没有足够的空闲 Region 来存放存活对象,就会触发 Evacuation Failure,退化为一次 Full GC(单线程标记-整理,JDK 10 后改为多线程)。这是 G1 调优中需要极力避免的情况。
G1 将堆划分为约 2048 个等大的 Region,每个 Region 可以动态扮演 Eden/Survivor/Old/Humongous 角色。回收分为 Young GC(只回收年轻代 Region)和 Mixed GC(年轻代 + 部分老年代 Region)。G1 通过"Garbage First"策略,优先选择垃圾比例最高的 Region 回收,使得在用户设定的目标停顿时间(-XX:MaxGCPauseMillis)内回收最多的垃圾。G1 使用 SATB 算法做并发标记,每个 Region 维护 RSet 来记录跨 Region 引用。它是 JDK 9+ 的默认收集器,适合中等大小堆(4GB~64GB)的延迟敏感应用。
ZGC:亚毫秒停顿的终极方案
ZGC(Z Garbage Collector)是 JDK 11 引入的实验性收集器,JDK 15 正式生产可用。它的目标极其大胆:停顿时间不超过 1 毫秒,且不随堆大小增长。无论堆是 8GB 还是 16TB,停顿时间都保持在亚毫秒级别。
G1 已经把停顿控制到 200ms 了,为什么还需要 ZGC?
因为 200ms 对于某些场景来说太长了。高频交易、实时广告竞价、在线游戏服务器——这些场景要求 P99 延迟在个位数毫秒。如果 GC 一次就停顿 200ms,整个 SLA 就崩了。ZGC 的目标是把 GC 停顿压缩到亚毫秒级,让 GC 对应用来说几乎"不可见"。
核心机制一:染色指针(Colored Pointers)
ZGC 最核心的创新是染色指针——利用 64 位指针中的空闲位来存储 GC 元数据。在 64 位系统中,虚拟地址空间远大于实际物理内存,指针的高位通常有多余的 bit。ZGC 利用这些 bit 来存储四个标记:
核心机制二:读屏障(Load Barrier)
当应用线程通过指针读取对象引用时,ZGC 会在读取前插入一段读屏障代码。读屏障会检查指针的染色位:
- 如果指针的 Remapped 位被设置,说明对象已经被 GC 转移到了新地址,读屏障会自动修正指针指向新地址
- 这样就不需要像 G1 那样在 STW 阶段批量修正所有引用——修正工作被分散到了每次指针读取时
核心机制三:并发转移(Concurrent Relocation)
ZGC 将 G1 中必须 STW 的 Evacuation(转移存活对象)阶段也变成了并发的:
- GC 线程并发将存活对象从旧 Region 复制到新 Region
- 旧地址的指针被标记为 Remapped(染色位)
- 应用线程下次通过读屏障访问该指针时,自动修正为新地址
- 当所有旧指针都被修正后,旧 Region 可以被安全回收
ZGC 的执行流程
| 阶段 | 类型 | 说明 |
|---|---|---|
| Pause Init Mark | STW(亚毫秒) | 标记 GC Roots 直接引用的对象 |
| Concurrent Mark | 并发 | 遍历整个对象图 |
| Pause Mark End | STW(亚毫秒) | 处理引用队列、软引用等 |
| Concurrent Prepare for Relocate | 并发 | 构建转移集合 |
| Pause Relocate Start | STW(亚毫秒) | 转移 GC Roots 引用的对象 |
| Concurrent Relocate | 并发 | 转移存活对象 + 读屏障自动修正指针 |
注意:所有 STW 阶段都是亚毫秒级的,最长的 Pause Mark End 通常也不超过 1ms。
# JDK 11(实验性) java -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -jar app.jar # JDK 15+(生产可用,无需 UnlockExperimental) java -XX:+UseZGC -jar app.jar # JDK 17+ 启用分代 ZGC(进一步提升吞吐量) java -XX:+UseZGC -XX:+ZGenerational -jar app.jar # ZGC 默认最大堆 16TB,适用于超大堆场景
ZGC 的取舍
| 维度 | 表现 | 说明 |
|---|---|---|
| 停顿时间 | 极好(<1ms) | 所有 STW 阶段都在亚毫秒级 |
| 吞吐量 | 中等偏低 | 读屏障有额外开销,CPU 使用率比 G1 高 ~10% |
| 内存开销 | 较高 | 需要额外的 Region Map、转移表等元数据 |
| 适用堆大小 | 8GB ~ 16TB | 大堆下优势最明显 |
| CPU 要求 | 多核 | 需要较多 CPU 核心给并发 GC 线程 |
ZGC 的核心目标是亚毫秒停顿且不随堆大小增长。它通过三个关键技术实现:① 染色指针——将 GC 标记信息嵌入 64 位指针的空闲 bit 中,避免了额外的元数据结构;② 读屏障——在应用线程读取对象引用时自动修正被转移的指针,将批量修正工作分散化;③ 并发转移——将 G1 中必须 STW 的 Evacuation 阶段变为并发,配合读屏障实现无停顿的对象转移。代价是吞吐量略低于 G1(读屏障开销约 10%),内存开销更大。JDK 15 后生产可用,JDK 17 引入了分代 ZGC 进一步改善吞吐量。
五代收集器全维度对比
| 维度 | Serial | Parallel | CMS | G1 | ZGC |
|---|---|---|---|---|---|
| 停顿时间 | 毫秒~秒级 | 毫秒~秒级 | 10~100ms | 10~200ms(可调) | <1ms |
| 停顿可控性 | 不可控 | 粗略可调 | 不可控(CMF 退化) | 目标值可调 | 硬保证 <1ms |
| 吞吐量 | 低(小堆尚可) | 最高 | 中等 | 中高 | 中等 |
| 内存开销 | 极低 | 低 | 中(需预留空间) | 中高(RSet) | 高(染色指针+元数据) |
| CPU 开销 | 最低 | GC 时高 | 持续占用 1~2 核 | GC 时高 | 持续占用(读屏障) |
| 堆大小 | <100MB | <8GB | <8GB | 4GB~64GB | 8GB~16TB |
| 默认 JDK 版本 | Client VM | JDK 8 | 无(JDK 14 移除) | JDK 9+ | JDK 15+(生产可用) |
| 碎片问题 | 无(Compact) | 无(Compact) | 严重 | 无(复制) | 无(复制) |
| 浮动垃圾 | 无 | 无 | 有 | 有(较轻) | 有(较轻) |
| 典型场景 | 嵌入式/小容器 | 批处理/后台计算 | Web 服务(已淘汰) | 通用 Web 服务 | 超低延迟/大堆 |
GC 演进时间线
JDK 中还有两个值得一提的收集器:Shenandoah(Red Hat 开发,目标类似 ZGC 的低停顿,但不使用染色指针而是用 Brooks Pointer 转发)和 Epsilon(不做任何回收,用于性能测试和短生命周期应用)。面试中提到它们可以作为加分项,但不需要深入展开。
你的项目该用哪个收集器?
选择决策树
/* * 第一步:你的堆有多大? */ if (heapSize < 100MB && cpuCores == 1) { return Serial; // 小堆单核,简单就是美 } if (heapSize < 4GB && 吞吐量优先) { return Parallel; // 批处理、后台任务 } if (heapSize < 64GB && 通用场景) { return G1; // JDK 9+ 默认,大多数场景的最优选 } if (延迟敏感 || heapSize > 16GB || P99要求 < 10ms) { return ZGC; // 超低延迟,JDK 15+ } // 默认:用 G1,别折腾 return G1;
生产环境 GC 监控指标
| 指标 | 含义 | 健康范围 | 告警阈值 |
|---|---|---|---|
| GC Pause Time | 单次 GC 停顿时长 | G1: <200ms / ZGC: <1ms | 超过目标值 2 倍 |
| GC Frequency | GC 触发频率 | Young: 每几分钟一次 | 每分钟超过 1 次 |
| GC Time Ratio | GC 时间占总时间比 | <5% | >10% |
| Full GC 次数 | Full GC 触发次数 | 0(不应发生) | 任何 Full GC 都要告警 |
| Heap Usage Trend | 堆使用率趋势 | 锯齿状波动 | 持续上升不回落 |
# JDK 9+ 统一日志 java -Xlog:gc*,gc+heap=debug:file=gc-%t.log:time,uptime,level,tags:filecount=5,filesize=10M # JDK 8 传统参数 java -XX:+PrintGCDetails -XX:+PrintGCDateStamps \ -Xloggc:/var/log/gc-%t.log \ -XX:+UseGCLogFileRotation \ -XX:NumberOfGCLogFiles=5 \ -XX:GCLogFileSize=10M
面试官问"你们线上用的什么 GC?怎么调优的?"怎么回答?
好的回答模板:"我们线上用的是 G1 / ZGC,堆大小 X GB。核心关注三个指标:单次停顿时间不超过 N ms(通过 -XX:MaxGCPauseMillis 设定)、GC 时间占比低于 5%(通过 GC 日志分析)、不发生 Full GC。曾经遇到过 Mixed GC 不及时导致的 Evacuation Failure,通过调低 InitiatingHeapOccupancyPercent 从 45 到 35 解决,让并发标记更早启动。"
全文核心记忆点:
- Serial:单线程全程 STW,适合小堆单核
- Parallel:多线程 STW,吞吐量最高,JDK 8 默认
- CMS:并发标记-清除,低停顿但有浮动垃圾、并发模式失败、内存碎片三大问题,JDK 14 移除
- G1:Region 化堆 + Mixed GC + SATB,可预测停顿,JDK 9+ 默认,适合大多数场景
- ZGC:染色指针 + 读屏障 + 并发转移,亚毫秒停顿,JDK 15 生产可用,适合大堆/超低延迟
JDK 的 GC 演进是一条"降低停顿"的主线:Serial 全程 STW → Parallel 多线程缩短 STW → CMS 将标记与用户线程并发 → G1 用 Region 化 + 增量回收实现可预测停顿 → ZGC 用染色指针和读屏障将停顿压到亚毫秒。每一代都是在吞吐量、延迟、内存三者之间的重新取舍。生产环境大多数情况用 G1,对延迟有极致要求或堆超过 16GB 时选 ZGC。