它们各自想解决什么
前面几款收集器(Serial、ParNew、Parallel Scavenge)的思路都是分代 + 整块停顿:回收的时候把用户线程全部停下来,收完再继续。区别主要在于能不能并行、用的是复制还是标记-整理。
问题在于堆越来越大,一次全停顿的时间越来越长,几百毫秒甚至几秒的卡顿在服务端扛不住。
CMS 和 G1 都是冲着这个来的,但走的路不一样:
CMS追求最短停顿,把耗时的标记和清除过程挪到和用户线程并发执行G1追求停顿时间可预测,在指定的时间预算内挑"最划算"的区域回收
CMS
只收老年代
CMS 全称 Concurrent Mark Sweep,它只管老年代,年轻代得配一个别的收集器,通常是 ParNew。
shell
-XX:+UseConcMarkSweepGC # 老年代用 CMS,年轻代自动配上 ParNew
四个阶段
text
1. 初始标记 ( STW )
只标记 GC Roots 能直接关联到的对象,很快
2. 并发标记
从这些根出发遍历整个对象图,最耗时,和用户线程并发跑
3. 重新标记 ( STW )
修正在第 2 步期间用户线程继续运行导致的标记变动,比第 1 步慢,比第 2 步快得多
4. 并发清除
清掉标记为死亡的对象,和用户线程并发跑
只有第 1 步和第 3 步是停顿的,而且都很短,这就是 CMS 能做到低停顿的原因。
第 3 步为什么必须停顿?因为第 2 步是并发的,用户线程一边跑一边改引用,标记结果会不准,得停下来把这个偏差修正掉。
标记-清除的代价:内存碎片
CMS 用的是标记-清除,不移动对象,所以会产生碎片。
碎片本身不致命,致命的是碎片多了之后,明明老年代还有空间,却没有连续空间放下一个大对象 ,只能提前触发一次 Full GC。而且 CMS 的 Full GC 会退化成 Serial Old 的单线程收集,停顿时间比平时长得多。
参数 -XX:+UseCMSCompactAtFullCollection(默认开启)可以在 Full GC 时做一次压缩整理,但整理是不能并发的,停顿会更长。-XX:CMSFullGCsBeforeCompaction 控制多少次 Full GC 之后压缩一次。
Concurrent Mode Failure
这是 CMS 最容易出问题的地方。
并发清除阶段用户线程还在跑,还在产生新的垃圾,这些新垃圾这一次清不掉,只能留到下一次,这叫浮动垃圾。因为会有浮动垃圾,CMS 不能等老年代快满了才开始收,得提前留出一块空间。
-XX:CMSInitiatingOccupancyFraction 控制触发阈值,默认 92%,意思是老年代用到 92% 就开始 CMS 回收。
如果预留的空间不够(回收速度赶不上分配速度),就会触发 Concurrent Mode Failure。这时 CMS 顶不住了,退化成 Serial Old 做一次全停顿的 Full GC,停顿时间可能是平时的十几倍。日志里看到这个词,基本就是回收速度跟不上分配的征兆。
G1
Region 布局
G1 把堆切成大小相等的 Region(1MB 到 32MB,必须是 2 的幂,通过 -XX:G1HeapRegionSize 指定)。
text
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ E │ E │ S │ O │ O │ H │ │ E │
└────┴────┴────┴────┴────┴────┴────┴────┘
E = Eden S = Survivor O = Old H = Humongous
关键点在于这些 Region 逻辑上分代、物理上不连续。年轻代和老年代不再是一整块连续的内存,而是散落在堆各处的 Region 的集合。
这样带来一个 CMS 做不到的能力:回收可以只挑一部分 Region 做 ,不用整个老年代一起收。这是 G1 能做到"可预测停顿"的前提。
超过 Region 大小一半的大对象,会直接放进 Humongous 区域,连续占用若干个 Region。大对象走这条路是因为复制它太贵,G1 干脆让它单独待着,不被复制来复制去。
四个阶段
text
1. 初始标记 ( STW )
标记 GC Roots 直接关联的对象,同时修正 TAMS 指针
2. 并发标记
从根出发遍历对象图,和用户线程并发
3. 最终标记 ( STW )
处理并发标记期间记录下来的引用变动
4. 筛选回收 ( STW )
对每个 Region 按回收价值和成本排序,按停顿目标挑一批出来回收
前三个阶段和 CMS 长得几乎一样,真正的区别在第 4 步。
筛选回收:名字的由来
第 4 步不是把所有垃圾 Region 都收掉,而是先排序再挑:
text
对每个 Region 算一个回收价值 = 里面的垃圾占比
按价值从高到低排
在 -XX:MaxGCPauseMillis 允许的时间预算内,从前往后挑 Region 收
-XX:MaxGCPauseMillis 默认 200 毫秒,G1 会基于历史数据预测回收每个 Region 大概要多久,然后挑出一批能在预算内收完的。这就是 "Garbage First" 这个名字的由来:先收垃圾最多的那些 Region。
代价是不能保证每次都能把所有垃圾收干净,垃圾少但还有用的 Region 会留到下一轮。换来的是停顿时间可控。
回收的算法
Region 之间是复制 (把存活对象挪到空 Region 里再整个清空原 Region),Region 内部是整块清理。所以整体上看是标记-整理,不会有 CMS 那样越用越碎的碎片问题。
G1 也有 Full GC,通常在 Mixed GC 跟不上分配速度、或者 Humongous 分配失败时触发。JDK 10 之前它就是 Serial Old 的单线程全停顿,10 之后换成了并行的。
关键差异
对象消失:增量更新 vs SATB
这是两个收集器在实现上最本质的分歧。
并发标记期间用户线程在改引用,会出现两种"标错":
- 浮动垃圾:标成活的,标完又变成死的。这个无所谓,下次再收
- 对象消失:标成死的,但它其实是活的。这个会出大事,活对象被当垃圾回收掉
对象消失的触发条件(Wilson 证明的)是两个条件同时成立:
text
1. 一个黑色对象(已经标完的)新增了一条指向白色对象的引用
2. 从 GC Roots 到这个白色对象的所有其他引用路径都被断掉了
只要破坏其中任意一个条件就能避免。两个收集器分别选了不同的那个:
| CMS | G1 | |
|---|---|---|
| 手段 | 增量更新(Incremental Update) | 原始快照(SATB) |
| 记什么 | 黑色对象新增的引用 | 被删除的旧引用 |
| 破坏哪个条件 | 条件 1 | 条件 2 |
| 重新标记阶段怎么做 | 以记录下来的黑对象为根,重新扫一遍 | 以记录下来的旧引用为根,把这些对象当成活的 |
增量更新的思路是"新增的引用不能漏",所以把新增记录记下来,重新标记时顺着再扫一遍。
SATB( Snapshot At The Beginning ) 的思路完全不同,它是假装对象图停在并发标记开始的那一刻。这期间被删掉的引用不算数,照样按活的算。所以它是记"删除了什么",而不是记"新增了什么"。
SATB 的副作用是产生更多浮动垃圾(明明已经不可达的对象还按活的留着),但换来了重新标记阶段更快,因为不用像 CMS 那样重新扫描一遍子树。
对比表
| 维度 | CMS |
G1 |
|---|---|---|
| 堆布局 | 物理上连续的老年代 | 一堆等大 Region,逻辑分代 |
| 回收算法 | 标记-清除 | 标记-整理(Region 间复制) |
| 内存碎片 | 有,碎片多了提前 Full GC | 没有 |
| 回收粒度 | 整个老年代 | 按 Region 挑,收一部分 |
| 停顿控制 | 只能调触发阈值,不能指定目标 | -XX:MaxGCPauseMillis 指定目标 |
| 大对象 | 直接进老年代 | 单独的 Humongous 区域 |
| 并发标记 | 增量更新 | SATB |
| 额外内存开销 | 较少 | Remembered Set 等,可能占堆的 1%~20% |
| 适合的堆 | 中小堆,4~8G 左右 | 大堆,6G 以上优势明显 |
| JDK 状态 | 9 标记废弃,14 移除 | 9 起是默认收集器 |
怎么选
到了 JDK 14 之后其实没什么可选的,CMS 已经被移除了,G1 是默认值,堆特别大或者对延迟有极端要求的会考虑 ZGC。
如果还在用 JDK 8 纠结这个问题,判断依据是堆大小和延迟要求:
- 堆在 4~8G 以内,延迟要求不算极端,
CMS够用,调好CMSInitiatingOccupancyFraction避开 Concurrent Mode Failure 就行 - 堆更大(十几 G 以上),或者不想花精力调参、希望有个能指定的停顿目标,选
G1 - 堆很小(几百 M),
G1的 RSet 开销占比会偏高,反而不如CMS或者Parallel
需要注意 G1 的 MaxGCPauseMillis 设得太激进(比如 20ms)会适得其反:预算是死的,回收的 Region 就少,垃圾清理跟不上,Mixed GC 频繁触发,反而更慢。