你如果在线上遇到过这类问题:
- 接口 RT 偶尔抖一下,P99/P999 很难稳
- Full GC 偶发一次,停顿几秒甚至十几秒
- 堆不小,但又不敢把 STW 拉太长
那你问"为什么选 G1",本质是在问:
- 我能不能让 GC 停顿更可控?
- 我能不能用更小粒度回收,而不是动不动整代回收?
G1 之所以重要,是因为它把"回收粒度"和"停顿目标"变成了可解释、可观测、可调的工程问题。
这篇按一条主线把 G1 串起来:
- Region:把堆切碎,回收粒度变小
- RSet:解决跨 Region 引用,保证回收正确性
- 并发标记(三色标记 + SATB + 写屏障):让标记尽量不阻塞业务
- 停顿预测:按收益/成本挑回收集合,让停顿落在目标内
1. Region:为什么要把堆切碎
传统分代是"连续的大块空间",而 G1 用 Region 化的意义是:
- 回收不再只能按"整代"进行
- 可以挑选收益高的 Region 回收
- 为可预测停顿提供基础
你可以把 Region 理解成:
- 堆的最小管理单元
一个非常关键的工程意义:
- G1 不是只能按"整代"回收,它可以按"哪些 Region 值得回收"来回收
- 这为"可预测停顿"提供了基础
2. 跨 Region 引用:为什么一定需要 RSet(以及它的代价)
Region 化后会出现一个问题:
- A Region 里的对象引用了 B Region 里的对象
当你只回收 B Region 时,你必须知道:
- 是否有别的 Region 在引用 B Region 里的对象
否则会误回收。
这就是 Remembered Set(RSet)的作用:
- 记录"哪些 Region 引用了我"
工程上你要知道的代价:
- RSet 会占用额外内存(一定会吃掉一部分堆外/堆内元数据开销,依实现而定)
- 维护 RSet 需要写屏障开销(每次写引用,都可能要更新相关记录)
为了把 RSet 的维护做得更可控,G1 通常还会配合类似"卡表(Card Table)"这样的粗粒度记账方式:
- 把内存划成更小的卡片(card)
- 写屏障把"某个区域的某张卡被写过"记录下来
- 后续回收时再基于这些记录去更新/扫描
3. 并发标记:三色标记 + SATB + 写屏障(你要理解的不是名词,而是矛盾)
三色标记是一个思维模型:
- 白色:未访问(可能是垃圾)
- 灰色:已发现但子引用未完全扫描
- 黑色:已扫描完(认为存活)
并发标记时,难点在于:
- 标记线程在遍历对象图的同时,业务线程仍在修改引用关系
这就是为什么 G1(以及很多并发收集器)会引入:
- 写屏障(Write Barrier):在"写引用"发生时插入一小段逻辑,维护并发标记需要的辅助信息
- SATB(Snapshot-At-The-Beginning):以"标记开始时的对象图快照"作为基准,处理并发修改带来的不一致
你不用背到源码级,但要会解释清楚:
- 并发标记要解决"对象图在变化"的一致性问题,否则会出现误标/漏标
- 写屏障与 SATB 是为了在可控成本下,把并发标记变成"结果正确"
4. G1 的回收主流程:Young GC、并发标记、Mixed GC
只说"G1 把堆切成 Region"是不够的,你需要能把主流程讲清楚。
你可以按这个顺序理解:
- Young GC(年轻代回收):优先回收年轻代 Region,停顿相对短
- 并发标记(Concurrent Mark):标记存活对象,为后续 Mixed 回收提供依据
- Mixed GC(混合回收):把一部分"收益高"的老年代 Region 加入回收集合
这就是为什么很多在线服务会选择 G1:
- 年轻代回收保持高频低成本
- 老年代回收不一定要等到非常满才来一次很重的 Full GC,而是能被 Mixed 分摊
5. 停顿预测:G1 的"可控"从哪里来
G1 的目标是:
- 尽量满足你设定的停顿目标(比如 200ms)
它的做法大致是:
- 统计每个 Region 的回收收益(可回收垃圾比例)与成本(预计回收耗时)
- 在一次回收中选择一批 Region(称为回收集合)
- 让这批 Region 的回收成本尽量落在停顿预算内
因此你看到的工程现象:
- G1 的停顿相对更"可预测"(但不是绝对保证)
- 代价是更复杂的管理结构(Region/RSet/写屏障)
6. 工程上怎么观测与排障:先会看现象,再谈"调参"
G1 调参之前,你至少要能回答:
- 现在卡顿来自 Young GC 还是 Mixed GC?
- 停顿时间主要花在"扫描引用"还是"对象搬迁(Evacuation)"?
- 是不是存在晋升失败、老年代增长过快、或回收跟不上分配?
6.1 你可以先用这些命令做低侵入确认
- 查看堆结构:
jcmd <pid> GC.heap_info - 查看线程是否 STW 后堆栈堆在一起:
jcmd <pid> Thread.print(卡顿时抓多次对比) - 查看对象大户:
jcmd <pid> GC.class_histogram
6.2 如果有 GC 日志,你重点盯这些关键词
Pause Young (Normal):年轻代回收Pause Young (Mixed):混合回收(年轻代 + 部分老年代 Region)Evacuation Failure/to-space exhausted:搬迁空间不够(危险信号)
你不需要一开始就把每个字段都读懂,但要形成"把一次停顿拆成模块"的习惯:
- 哪一段耗时最大
- 是不是某类对象导致搬迁成本过高
7. 常见调参思路(只讲工程上常用、且容易解释清楚的)
这部分我建议你把"目标 -> 动作 -> 代价"讲出来,而不是背参数。
7.1 目标:停顿更可控
- 动作:设置一个合理的停顿目标(例如
-XX:MaxGCPauseMillis=200) - 代价:为了满足停顿目标,G1 可能更频繁地回收,吞吐会有损失
7.2 目标:减少 Mixed/Old 压力
- 动作:关注晋升是否过快、对象是否真的长期存活(缓存/集合/大对象)
- 代价:本质往往是业务对象生命周期问题,不是单靠参数能解决
8. 总结:你讲得清楚 G1,就讲得清楚"为什么要它"
- Region:把回收粒度切小,支持按收益选择回收集合
- RSet:解决跨 Region 引用,保证回收正确性(但有内存与写屏障代价)
- 并发标记:三色标记 + SATB + 写屏障,是为了解决"对象图在变化"的一致性
- 回收主流程:Young GC -> 并发标记 -> Mixed GC,尽量避免一次巨大的 Full GC
- 停顿预测:基于收益/成本模型选择回收集合,让停顿更接近目标