老系统升级 JDK 17 时最常遇到的一行报错是 Unrecognized VM option 'UseConcMarkSweepGC'。CMS 从 JDK 9 起被标记弃用、JDK 14 正式移除,但"为什么被移除"这个问题比"怎么迁移"更值得搞清:CMS 的三个结构性问题至今仍在影响 G1 的调参直觉。
1. CMS 的四个阶段
| 阶段 | 是否 STW | 做什么 | 关键点 |
|---|---|---|---|
| 初始标记 | 是(短) | 标记 GC Roots 直接可达对象 | 依赖 Young GC 的停顿,速度快 |
| 并发标记 | 否 | 三色标记遍历对象图 | 与业务线程并发,需写屏障维护 |
| 重新标记 | 是(较长) | 修正并发期间的引用变化 | 处理卡表与新生代引用,停顿敏感 |
| 并发清除 | 否 | 清扫未标记对象 | 不压缩,产生碎片 |
两个 STW 点是初始标记与重新标记,其中重新标记是最容易出问题的:它要重扫并发期间所有被修改的引用,堆越大、业务越活跃,这一步越久。
# CMS 时代的典型参数(JDK 8)
$ java -XX:+UseConcMarkSweepGC \
-XX:CMSInitiatingOccupancyFraction=70 \
-XX:+UseCMSInitiatingOccupancyOnly \
-XX:CMSInitiatingPermOccupancyFraction=80 \
-XX:+CMSScavengeBeforeRemark \
-XX:ParallelGCThreads=8 -Xms4g -Xmx4g -jar app.jar
2. CMS 的三大问题
| 问题 | 机制 | 后果 | 相关参数 |
|---|---|---|---|
| 浮动垃圾 | 并发标记期间新产生的垃圾本轮标不到 | 本轮不回收,只能等下一轮 | CMSInitiatingOccupancyFraction 调低留余量 |
| 并发模式失败 | 老年代增长快于并发清理,空间耗尽 | 退化为单线程 Full GC(Serial Old),长停顿 | 同上 + 提前触发 |
| 碎片 | 清除不压缩 | 大对象分配找不到连续空间 → promotion failed | -XX:UseCMSCompactAtFullCollection(默认开) |
"浮动垃圾"是设计上的必然代价:并发阶段不能把新分配对象全标一遍,只能下一轮再说。所以 CMS 必须提前启动,留出足够余量——这就是 CMSInitiatingOccupancyFraction 的本质:它不是调优参数,是安全边际。
Concurrent mode failure 的现场读法:
典型日志形态(CMS 时代输出截取):
[GC (CMS Initial Mark) ...]
[GC (CMS Final Remark) ...]
[Full GC (Allocation Failure) ... [CMS: 3.9G->3.8G]
出现 Full GC (Allocation Failure) 且伴随 CMS: old->old 几乎不降,说明并发清理没跑赢分配速率。此时先看 CMSInitiatingOccupancyFraction 是否太高(比如 92),再考虑堆是否真的不够。
3. 移除与迁移
| 版本 | 事件 |
|---|---|
| JDK 9 | CMS 被标记为 deprecated(JEP 是弃用通告) |
| JDK 14 | CMS 正式移除(JEP 363) |
| JDK 17 | 无 CMS;G1 为默认;可选 ZGC / Shenandoah |
| JDK 21+ | 分代 ZGC 出现 |
迁移不是把参数换个名字,而是目标函数变了:CMS 优化的是"降低单次停顿",G1 优化的是"在停顿目标内做取舍"。旧系统的低延迟诉求在 JDK 17 上应该先评估 G1 的 MaxGCPauseMillis,再考虑 ZGC。
$ java -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -Xms4g -Xmx4g -jar app.jar
$ java -XX:+UseZGC -Xms4g -Xmx4g -jar app.jar # JDK 17 的 ZGC,单代
$ java -XX:+PrintGCDetails -Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=5,filesize=50m -jar app.jar
4. G1 的区域化堆与软目标
G1 把堆切成等大的 Region(默认按堆大小推导,-XX:G1HeapRegionSize 可指定,取值 1MB–32MB 的 2 的幂):
| 概念 | 含义 | 影响 |
|---|---|---|
| Region | 堆的逻辑分块,角色可变(Eden/Survivor/Old/Humongous) | Region 数决定管理粒度 |
| Collection Set | 本轮要回收的 Region 集合 | 由停顿模型挑选 |
| Remembered Set | 记录跨 Region 引用 | 写屏障维护,有固定开销 |
MaxGCPauseMillis |
软目标,不是硬承诺 | 收集器尽力而为 |
-XX:MaxGCPauseMillis 的软目标语义必须说清楚:G1 会据此估算"一轮能收多少个 Region",但当回收收益不足时它会放弃达标。如果堆里全是长寿对象,Region 回收后腾不出空间,G1 只能继续收更多 Region 或退化到 Full GC——这不是 bug,是目标函数冲突。
$ java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m \
-XX:G1NewSizePercent=5 -XX:G1MaxNewSizePercent=60 \
-XX:InitiatingHeapOccupancyPercent=45 -jar app.jar
IHOP(InitiatingHeapOccupancyPercent)是 G1 版的"提前启动"参数,对应 CMS 的 CMSInitiatingOccupancyFraction,但语义是"老年代占整堆比例达到此值就启动并发标记"。
5. G1 的三种失败
| 失败 | 触发条件 | 日志特征 | 处理 |
|---|---|---|---|
to-space exhausted |
Survivor 区不够,晋升失败 | to-space exhausted |
增大 SurvivorRatio / 提前晋升 / 降分配速率 |
Evacuation Failure |
疏散时目标 Region 无空间 | Evacuation Failure |
同上看;检查 IHOP 是否过高 |
| Humongous 分配 | 对象 ≥ Region 一半 | Humongous 区域反复回收 |
增大 G1HeapRegionSize 或减少大对象 |
Humongous 的阈值是 RegionSize / 2。默认 Region 由堆大小推导,2G 堆上 Region 可能是 1MB,那么 512KB 以上的对象就是 Humongous——这在缓存批量加载、序列化大 payload 时非常容易触发。
$ java -Xlog:gc+humongous=debug -jar app.jar | head -20
典型输出形态(JDK 17 输出截取):
[0.512s][info][gc,humongous] GC(3) Humongous regions: 12->4
Humongous 对象在 Region 里连续存放,回收时必须整段释放,容易造成碎片与提前触发并发周期。这不是"内存不够",而是分配粒度与大对象不匹配。
6. CMS → G1 调参对照
| CMS 参数 | G1 对应 | 语义差异 |
|---|---|---|
CMSInitiatingOccupancyFraction |
InitiatingHeapOccupancyPercent |
CMS 是老年代占比,G1 是整堆占比 |
CMSScavengeBeforeRemark |
无需对应 | G1 的 Remark 前有并发清理,机制不同 |
CMSFullGCsBeforeCompaction |
无需对应 | G1 每轮都在疏散(复制),天然压缩 |
ParallelGCThreads |
ParallelGCThreads |
同名,但 G1 另有 ConcGCThreads |
-XX:+UseCMSCompactAtFullCollection |
无需对应 | 见上 |
SurvivorRatio |
G1NewSizePercent / G1MaxNewSizePercent |
G1 用比例区间而非固定比例 |
这张表最实用的一条是:CMS 的"占用阈值"换到 G1 后数值不能照搬,因为分母从老年代变成了整堆。把 70 直接搬到 IHOP 通常意味着"启动得太晚"。
7. 常见坑
| 坑 | 现象 | 真实原因 | 处理 |
|---|---|---|---|
把 MaxGCPauseMillis 当硬承诺 |
设 50ms,实际 200ms | 软目标,收益不足时放弃 | 调 Region 大小、减少长寿对象 |
| 大对象触发 Humongous 频繁回收 | 日志里 Humongous 反复出现 | 对象 ≥ Region/2 | 增大 Region 或拆对象 |
| 沿用 CMS 的占用阈值 | G1 并发周期启动太晚 | 分母不同 | 按整堆比例重算,通常 40–50 |
| 以为 G1 没有 Full GC | 出现 Pause Full (Allocation Failure) |
疏散失败后退化 | 查 to-space/Evacuation Failure |
关掉 IHOP 自适应 |
并发周期抖动 | JDK 17 默认自适应 IHOP | 谨慎显式设置 |
-XX:G1HeapRegionSize 设过大 |
小对象浪费空间 | Region 太大导致内部碎片 | 按对象分布选 1–4MB |
8. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
Unrecognized VM option 'UseConcMarkSweepGC' |
JDK 14+ 已移除 | 删参数改 G1/ZGC |
| 单次停顿远超目标 | 软目标未达成 | 看 -Xlog:gc 的 Pause Young 与 Region 数 |
| 频繁 Full GC | 疏散失败或 IHOP 过晚 | 查 to-space exhausted,降 IHOP |
| 老年代增长快但 GC 不频繁 | 分配速率高、年轻代过大 | 看分配速率与 G1NewSizePercent |
| 迁移后吞吐下降 | G1 写屏障与 RSet 开销 | 对比 -XX:ParallelGCThreads、评估 ZGC |
写屏障这一项值得单独说:G1 的 RSet 维护在每次引用赋值上都有开销,这是它相对 CMS 的固定税。对写密集、对象图复杂的应用,这笔税可能吃掉 CMS 时代的吞吐优势——迁移后吞吐下降往往来自这里,而不是参数没调好。
9. 边界
| 场景 | 建议 | 理由 |
|---|---|---|
| 堆 < 4G、低延迟 | G1 | 区域化管理粒度合适 |
| 堆 > 32G、极低延迟 | ZGC | G1 的 RSet 与疏散在大堆上代价上升 |
| 吞吐优先的批处理 | Parallel GC | 无并发开销,暂停换吞吐 |
| 小堆、短生命周期 | Serial GC | 开销最小 |
| 仍在 CMS 上的老系统 | 先在 JDK 17 上用 G1 跑基线 | 再决定是否上 ZGC |
10. 自测
- CMS 的四个阶段里哪两个会 STW,为什么重新标记通常比初始标记更贵?
- 浮动垃圾是怎么产生的,它如何逼出
CMSInitiatingOccupancyFraction这个参数? Concurrent mode failure在日志里长什么样,退化后的收集器是什么?MaxGCPauseMillis为什么是软目标?G1 在什么情况下会放弃达标?- G1 的 Humongous 阈值怎么算,为什么大对象会引发提前回收?
- CMS 的占用阈值迁移到 G1 时为什么不能照搬数值?