适合人群: Java后端工程师、性能调优工程师、技术面试准备者
难度等级: ⭐⭐⭐⭐ (中高级)
阅读时间: 20分钟
收益: 彻底搞懂G1和CMS,面试加分、调优有底!
📖 引言:两位"清洁工"的故事
想象一个大型购物中心(Java堆内存),每天有成千上万的顾客(对象)来来往往,产生大量垃圾。
现在有两位清洁工应聘:
- CMS先生 👴 - 资深老员工,经验丰富,但年纪大了,有些力不从心
- G1小姐 👩 - 新一代清洁能手,智能高效,但需要一定学习成本
老板(你)要选谁?这就是我们今天要探讨的问题!
剧透: 对于大型购物中心(大堆内存),G1明显更胜一筹!🎯
🎭 第一章:认识两位选手
1.1 CMS (Concurrent Mark Sweep) - 老将风采 👴
全名: 并发标记清除垃圾回收器
诞生时间: JDK 1.4(2002年)
性格特点: 追求低延迟,但有点"洁癖"(会产生内存碎片)
工作方式: 像一个传统清洁工,扫描整个楼层,边工作边清理
CMS的工作流程(4步舞)💃:
1. 初始标记 (Initial Mark) - STW ⏸️
└─ "停一下!我要标记一下根对象!"
2. 并发标记 (Concurrent Mark) - 并行 🏃
└─ "你们继续工作,我边扫边标记垃圾"
3. 重新标记 (Remark) - STW ⏸️
└─ "再停一下!确认一下哪些是垃圾!"
4. 并发清除 (Concurrent Sweep) - 并行 🧹
└─ "你们继续,我把垃圾清走"
优点: ✅
- 停顿时间短(并发执行)
- 适合对延迟敏感的应用
缺点: ❌
- 内存碎片严重(清除算法,不压缩)
- CPU资源占用高(并发执行抢占CPU)
- 无法处理"浮动垃圾"
- 大堆场景下效率低
1.2 G1 (Garbage First) - 新秀崛起 👩💼
全名: 垃圾优先垃圾回收器
诞生时间: JDK 7(2012年),JDK 9后成为默认GC
性格特点: 聪明、高效、可预测停顿时间
核心思想: 把整个堆划分为小区域(Region),优先回收垃圾最多的区域!
G1的"分区治理"策略 🗺️:
整个堆被切成2048个Region(每个1-32MB)
┌─────────────────────────────────┐
│ E E S E E O O H E E │
│ E S E O O O H E E E │
│ O O O H E E E S E E │
└─────────────────────────────────┘
E = Eden(伊甸园)
S = Survivor(幸存区)
O = Old(老年代)
H = Humongous(巨大对象)
G1的工作流程:
1. Young GC (年轻代回收) 🧒
└─ 回收所有Eden区和Survivor区
2. Mixed GC (混合回收) 🎭
└─ 回收年轻代 + 部分老年代(垃圾最多的)
3. Full GC (全堆回收) 💥
└─ 实在不行了,来次大扫除(极少发生)
优点: ✅
- 可预测的停顿时间(可设置目标)
- 没有内存碎片(使用复制算法+整理)
- 大堆场景下性能优秀
- 不需要配合其他回收器
缺点: ❌
- 小堆场景下不如CMS
- 内存占用稍高(需要Remember Set)
⚔️ 第二章:巅峰对决 - 5个维度全面PK
🥊 Round 1: 内存布局 - G1胜!
CMS的"传统公寓"布局:
┌──────────────────────────────┐
│ 年轻代(Young Gen) │
│ ┌─────┬──────┬──────┐ │
│ │Eden │ S0 │ S1 │ │
│ └─────┴──────┴──────┘ │
├──────────────────────────────┤
│ 老年代(Old Gen) │
│ │
│ 一整块连续的内存空间 │
│ │
└──────────────────────────────┘
问题:
- ❌ 老年代是一整块,容易产生碎片
- ❌ 必须整体回收,无法分块处理
- ❌ 大堆场景下,扫描整个老年代耗时长
G1的"智能分区"布局:
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ E │ E │ E │ S │ O │ O │ H │ │
├───┼───┼───┼───┼───┼───┼───┼───┤
│ E │ E │ S │ O │ O │ O │ │ E │
├───┼───┼───┼───┼───┼───┼───┼───┤
│ O │ O │ H │ E │ E │ E │ S │ O │
└───┴───┴───┴───┴───┴───┴───┴───┘
每个格子 = 1个Region (独立回收单元)
优势:
- ✅ 化整为零,灵活回收
- ✅ 优先回收垃圾最多的Region
- ✅ 大堆场景下,不用一次扫描所有内存
生活比喻: 🏠
- CMS = 传统大平层,打扫必须全屋一起来
- G1 = 模块化公寓,可以只打扫最脏的几个房间
🥊 Round 2: 停顿时间可预测性 - G1完胜!
CMS的"随缘式"停顿:
// CMS无法精确控制停顿时间
-XX:+UseConcMarkSweepGC
// 实际停顿时间:看运气!🎲
Young GC: 50ms - 200ms(不确定)
Full GC: 几百毫秒 - 几秒(更不确定)
问题:
- ❌ 停顿时间不可控
- ❌ 大堆场景下,Full GC可能长达几秒
- ❌ 用户体验差
G1的"承诺式"停顿:
// G1可以设置停顿时间目标!🎯
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 // 目标停顿时间200ms
// G1会尽力达成目标
实际停顿: 大部分在200ms以内 ✅
G1如何做到? 🤔
G1的智能决策:
每次GC,G1会计算:
1. 哪些Region垃圾最多?
2. 回收多少个Region能在200ms内完成?
3. 选择性价比最高的Region回收!
就像:
- 有10个房间要打扫
- 你只有20分钟
- 优先打扫最脏的3个房间!
生活比喻: ⏰
- CMS = "我尽量快点"(不保证时间)
- G1 = "我保证20分钟内完成"(有承诺)
🥊 Round 3: 内存碎片处理 - G1碾压!
CMS的"致命弱点" - 碎片化 💔
CMS使用"标记-清除"算法:
回收前:
┌────────────────────────────────┐
│ ██░░░░██████░░██░░░░░░████░░░░ │
└────────────────────────────────┘
█ = 存活对象 ░ = 垃圾
回收后:
┌────────────────────────────────┐
│ ██ ██████ ██ ████ │ ⚠️ 到处是空洞!
└────────────────────────────────┘
此时来了一个大对象:
┌─────────────────┐
│ 大对象(10格) │
└─────────────────┘
放不下!❌ 明明有足够空间,但不连续!
触发 Full GC 进行压缩整理(超级慢!)
后果:
- ❌ 频繁的Full GC
- ❌ 停顿时间长达几秒
- ❌ 应用卡死
G1的"完美方案" - 无碎片 ✨
G1使用"复制算法":
回收前(Region A):
┌──────────────┐
│ ██░░░░██░░░░ │
└──────────────┘
回收时:把存活对象复制到新Region
┌──────────────┐ 复制 ┌──────────────┐
│ ██░░░░██░░░░ │ ───→ │ ████ │
└──────────────┘ └──────────────┘
Region A Region B
(回收掉) (紧凑无碎片)
优势:
- ✅ 完全没有碎片
- ✅ 不需要压缩整理
- ✅ 避免Full GC
生活比喻: 📦
- CMS = 书柜里东一本西一本,新书塞不进去
- G1 = 重新整理书柜,所有书紧密排列
🥊 Round 4: 大堆场景性能 - G1完胜!
性能对比测试 📊
测试场景:
- 堆大小:32G
- 对象分配速率:高
- 存活对象:较多
┌──────────────────────────────────────┐
│ CMS vs G1 性能对比 │
├──────────────┬──────────┬────────────┤
│ 指标 │ CMS │ G1 │
├──────────────┼──────────┼────────────┤
│ Young GC频率 │ 很高 │ 中等 │
│ Young GC耗时 │ 150ms │ 100ms │
│ Full GC频率 │ 频繁💥 │ 极少✅ │
│ Full GC耗时 │ 3-5秒😱 │ 很少发生 │
│ 吞吐量 │ 85% │ 95%✅ │
│ 最大停顿 │ 5秒💔 │ 200ms✅ │
│ 内存碎片 │ 严重❌ │ 无✅ │
└──────────────┴──────────┴────────────┘
为什么G1在大堆场景下更优?
1. 分区回收,化整为零 🎯
32G堆内存:
CMS:
- 老年代20G,必须整体扫描
- 扫描20G内存 → 耗时长
- 停顿时间不可控
G1:
- 2048个Region,每次只回收最脏的几个
- 比如只回收100个Region(约1.5G)
- 停顿时间可控在200ms内
2. 避免Full GC 🚫
CMS:
内存碎片 → 大对象分配失败 → Full GC → 长时间停顿
G1:
无碎片 → 大对象可分配 → Mixed GC → 短时间停顿
3. 并发标记更高效 ⚡
CMS:
- 标记整个堆
- 大堆场景下,并发标记时间长
- 容易产生"浮动垃圾"
G1:
- 基于Region标记
- 并发标记+SATB算法
- 效率更高
🥊 Round 5: 调优复杂度 - 平手!
CMS的调优参数(复杂!) 🤯
-XX:+UseConcMarkSweepGC # 启用CMS
-XX:+UseParNewGC # 年轻代使用ParNew
-XX:CMSInitiatingOccupancyFraction=75 # 老年代75%时触发CMS
-XX:+UseCMSCompactAtFullCollection # Full GC时压缩
-XX:CMSFullGCsBeforeCompaction=0 # 每次Full GC都压缩
-XX:+CMSParallelRemarkEnabled # 并行重标记
-XX:+CMSScavengeBeforeRemark # 重标记前先Young GC
-XX:ParallelGCThreads=8 # 并行GC线程数
-XX:ConcGCThreads=2 # 并发GC线程数
# 参数超多,需要精细调优!😵
G1的调优参数(简单!) 😊
-XX:+UseG1GC # 启用G1
-XX:MaxGCPauseMillis=200 # 停顿时间目标
-XX:G1HeapRegionSize=16m # Region大小(可选)
-XX:InitiatingHeapOccupancyPercent=45 # 触发并发标记的阈值
# 大部分时候,只需要设置MaxGCPauseMillis!👍
结论:
- CMS需要大量调优经验
- G1开箱即用,自适应强
📊 第三章:核心算法原理深度解析
3.1 G1的"分区设计" - 为什么这么牛?🏗️
Region的大小计算
Region大小 = 堆大小 / 2048
例如:
- 4G堆 → Region = 2MB
- 8G堆 → Region = 4MB
- 32G堆 → Region = 16MB
- 64G堆 → Region = 32MB
Region的动态角色 🎭
同一个Region可以动态变化角色!
时刻1:
Region #100 = Eden区(存放新对象)
Young GC后:
Region #100 = 空闲(被回收)
时刻2:
Region #100 = Old区(存放老对象)
Mixed GC后:
Region #100 = 空闲(又被回收)
灵活性: ✨
- 不用固定年轻代和老年代大小
- 根据实际情况动态调整
- 最大化利用内存
3.2 G1的"Mixed GC" - 核心优势所在 🎯
什么是Mixed GC?
Mixed GC = Young GC + 部分Old GC
┌────────────────────────────────────┐
│ 回收所有年轻代Region │
│ + │
│ 回收垃圾最多的几个老年代Region │
└────────────────────────────────────┘
Mixed GC的工作流程
第1步:并发标记(找出老年代各Region的垃圾量)
┌───┬───┬───┬───┬───┬───┐
│ O │ O │ O │ O │ O │ O │
│30%│80%│45%│90%│20%│60%│ ← 垃圾占比
└───┴───┴───┴───┴───┴───┘
第2步:根据MaxGCPauseMillis计算能回收多少个
目标200ms → 预计能回收3个Region
第3步:选择垃圾最多的3个Region
┌───┬───┬───┬───┬───┬───┐
│ O │ O │ O │ O │ O │ O │
│30%│80%│45%│90%│20%│60%│
✅ ✅ ✅
第4步:Mixed GC(回收年轻代 + 这3个老年代Region)
收益:
- ✅ 每次只回收一部分老年代
- ✅ 停顿时间可控
- ✅ 逐步清理老年代,避免Full GC
3.3 G1的"SATB算法" - 解决并发标记难题 🧠
问题:并发标记时对象引用会变化
并发标记阶段:
时刻1:A → B → C(C是垃圾)
✅标记 ✅标记 准备标记...
时刻2:应用线程突然改变引用
A → D(新对象)
B 断开了 → C的引用
此时C会被漏标记!❌
SATB (Snapshot At The Beginning) 解决方案
核心思想:保留"开始快照"时的引用关系
1. 并发标记开始时,记录此刻的对象引用快照
2. 标记期间,如果引用发生变化,记录到队列
3. 重新标记阶段处理这些变化
结果:
- 不会漏标记存活对象 ✅
- 可能多标记一些(保守策略)
- 下次GC会处理
生活比喻: 📸 就像拍合影,有人动了也没关系,按快门瞬间的位置来!
🎓 第四章:实战案例分析
案例1:从CMS迁移到G1 - 性能提升3倍!🚀
背景
- 电商系统,32G堆内存
- CMS频繁Full GC,每次3-5秒
- 用户投诉支付卡顿
问题分析
# CMS的GC日志
[Full GC (Allocation Failure)
20G->18G(30G), 4.2345678 secs] # 😱 4秒停顿!
# 发现:
1. Full GC频率:每10分钟一次
2. 每次Full GC停顿4秒
3. 用户支付时遇到Full GC = 支付超时
解决方案
# 旧配置(CMS)
-Xms32g -Xmx32g
-XX:+UseConcMarkSweepGC
-XX:+UseParNewGC
-XX:CMSInitiatingOccupancyFraction=70
# ... 一堆参数
# 新配置(G1)
-Xms32g -Xmx32g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:G1HeapRegionSize=16m
# 就这么简单!
效果对比
┌─────────────────┬─────────┬─────────┐
│ 指标 │ CMS │ G1 │
├─────────────────┼─────────┼─────────┤
│ Full GC次数/天 │ 144次 │ 0次✅ │
│ 最大停顿时间 │ 5秒💔 │ 180ms✅│
│ 平均停顿时间 │ 400ms │ 80ms✅ │
│ 吞吐量 │ 82% │ 96%✅ │
│ 用户投诉 │ 频繁😭 │ 消失😊│
└─────────────────┴─────────┴─────────┘
案例2:G1参数调优 - 让停顿时间更稳定 ⚙️
问题:G1偶尔会超过停顿目标
# 初始配置
-XX:MaxGCPauseMillis=200
# GC日志显示
[GC pause (G1 Evacuation Pause) (young) 180ms] ✅
[GC pause (G1 Evacuation Pause) (young) 195ms] ✅
[GC pause (G1 Evacuation Pause) (young) 320ms] ❌ 超了!
[GC pause (G1 Evacuation Pause) (young) 190ms] ✅
原因分析
G1的停顿时间目标是"尽力而为",不是严格保证
超时原因:
1. Region数量选择过多
2. 对象复制耗时长
3. 引用更新耗时长
优化方案
# 调优参数
-XX:+UseG1GC
-XX:MaxGCPauseMillis=150 # 降低目标(留缓冲)
-XX:G1HeapRegionSize=16m # 显式设置Region大小
-XX:ParallelGCThreads=8 # 增加并行线程
-XX:ConcGCThreads=2 # 并发标记线程
-XX:InitiatingHeapOccupancyPercent=40 # 更早触发并发标记
# 效果:99%的GC都在150ms内!
案例3:巨型对象导致的Full GC 🐘
问题现象
# G1也会Full GC!
[Full GC (Allocation Failure) 28G->25G(32G), 3.1234567 secs]
# 什么?G1不是不会Full GC吗?😱
原因分析
// 代码中有这样的操作
byte[] hugeArray = new byte[100 * 1024 * 1024]; // 100MB
// 单个对象超过Region大小的50%
// 会被标记为Humongous对象
// 占用连续的多个Region
当Humongous对象太多,且无法回收时:
→ 触发Full GC!
解决方案
// ❌ 错误:创建巨型对象
byte[] data = loadBigFile(); // 200MB
// ✅ 正确:分块处理
try (InputStream is = new FileInputStream(file)) {
byte[] buffer = new byte[8192]; // 8KB缓冲
int len;
while ((len = is.read(buffer)) != -1) {
process(buffer, len);
}
}
// 避免创建超大对象!
🎯 第五章:选择指南 - 什么时候用G1?什么时候用CMS?
决策树 🌳
开始
│
├─ 堆内存 >= 6G?
│ │
│ ├─ 是 → 使用G1 ✅
│ │ (大堆场景,G1明显更优)
│ │
│ └─ 否 → 继续判断
│ │
│ ├─ 追求极致低延迟?
│ │ │
│ │ ├─ 是 → CMS或ZGC
│ │ │
│ │ └─ 否 → G1
│ │
│ └─ JDK版本 >= 9?
│ │
│ ├─ 是 → G1(默认)
│ │
│ └─ 否 → CMS(向后兼容)
使用场景对照表 📋
| 场景 | 推荐GC | 原因 |
|---|---|---|
| 大堆内存(>6G) | G1 ✅ | 停顿可控,无碎片 |
| 小堆内存(<4G) | CMS 或 G1 | 差别不大 |
| 电商/支付系统 | G1 ✅ | 稳定的停顿时间 |
| 实时游戏服务器 | ZGC/Shenandoah | 极低延迟 |
| 批处理任务 | Parallel GC | 追求吞吐量 |
| 微服务(内存小) | G1 | 简单易用 |
| 大数据处理(几十G堆) | G1 ✅✅ | 大堆王者 |
| 遗留系统(JDK 6/7) | CMS | 兼容性 |
📋 第六章:G1最佳实践和注意事项
✅ 最佳实践
1. 合理设置停顿时间目标
# ❌ 不要设置得太激进
-XX:MaxGCPauseMillis=50 # 太低了,G1压力大
# ✅ 推荐值
-XX:MaxGCPauseMillis=200 # 电商、互联网应用
-XX:MaxGCPauseMillis=500 # 对延迟不敏感的应用
2. 让G1自适应工作
# ❌ 不要过度调参
-XX:NewRatio=2
-XX:SurvivorRatio=8
# G1会动态调整,不需要手动设置!
# ✅ 简单配置即可
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
3. 堆大小设置
# ✅ 初始堆和最大堆设置相同
-Xms32g -Xmx32g
# 避免堆动态扩展/收缩,影响性能
4. 监控和日志
# 开启详细GC日志
-Xlog:gc*:file=/var/log/gc.log:time,level,tags
# 关键监控指标
- Mixed GC频率
- Mixed GC耗时
- Full GC次数(应该是0!)
⚠️ 注意事项
1. G1不是万能的
❌ 不适合G1的场景:
- 超小堆(<2G):开销相对大
- 极致延迟要求(<10ms):考虑ZGC
- 超大对象特别多:可能触发Full GC
2. G1也会Full GC
触发Full GC的原因:
1. 并发标记失败(并发标记速度 < 对象分配速度)
2. Humongous对象分配失败
3. 元空间不足
解决办法:
- 增大堆内存
- 提前触发并发标记(降低IHOP)
- 避免创建超大对象
3. Region大小选择
# 自动计算(推荐)
不设置,让G1自动计算
# 手动设置(特殊场景)
-XX:G1HeapRegionSize=16m
# 规则:
- 必须是2的幂次(1, 2, 4, 8, 16, 32)
- 范围:1MB - 32MB
- 对象 > Region大小50% = Humongous对象
💡 总结:核心要点
🎯 一句话总结
在大堆场景下(>6G),G1在停顿时间可预测性、内存碎片处理、整体性能上全面优于CMS,是首选!
🔑 关键知识点
-
G1的核心优势
- 分区设计(Region)
- 可预测的停顿时间
- 无内存碎片
- Mixed GC机制
-
CMS的致命弱点
- 内存碎片严重
- 大堆场景下Full GC频繁
- 停顿时间不可控
-
选择原则
- 大堆(>6G)→ G1
- 小堆 → G1或CMS都行
- 极低延迟 → ZGC/Shenandoah
- 高吞吐量 → Parallel GC
-
G1调优
- 简单配置即可,让其自适应
- 关键参数:MaxGCPauseMillis
- 避免创建超大对象
🎉 结语
恭喜你!🎊 你已经彻底掌握了:
- ✅ G1和CMS的核心区别
- ✅ G1为何在大堆场景下更优
- ✅ G1的分区设计和Mixed GC机制
- ✅ 实战案例和调优技巧
- ✅ 使用场景和选择指南
下次面试被问到"为什么G1比CMS好",你就可以从内存布局、停顿时间、碎片处理、Mixed GC等多个维度侃侃而谈了!🚀
记住:
"大堆用G1,小堆随意,追求吞吐用Parallel,极致延迟用ZGC!"
📚 扩展阅读
- 《深入理解Java虚拟机》第3章 - 垃圾收集器
- Oracle官方G1 GC文档
- G1 GC论文原文
💪 加油,GC调优大师!愿你的系统永不Full GC! 😄
最后更新: 2025年10月
作者: AI助手(用❤️和☕创作)
下一篇预告: 《ZGC:超低延迟的黑科技!》