面试官问"你们生产用哪个收集器",我答不上来
11 月中旬的一次面试,面试官问:"你们生产 JVM 用的什么垃圾收集器?为什么选它?"
我当时的回答是"默认的,没配过"。面试官点了点头没追问,但我知道这题挂了。回去之后把 GC 这块从头看了一遍,顺便把我们线上的 GC 日志翻出来分析了下,发现确实该调。
先说对象怎么判定死亡
有两种思路。引用计数法是给每个对象挂个计数器,有人引用就加一,引用失效就减一,归零就回收。Python 和 Objective-C 用的是这个。它简单,但解决不了循环引用:
class Node {
Node next;
}
Node a = new Node();
Node b = new Node();
a.next = b;
b.next = a;
a = null;
b = null;
// 两个对象互相引用,计数都是 1,但外部已经访问不到它们了
所以 JVM 不用引用计数,用的是可达性分析。从一组叫 GC Roots 的对象出发,沿着引用链往下搜,搜不到的对象就是死的。
GC Roots 包括这几类:
- 虚拟机栈(栈帧的本地变量表)里引用的对象
- 方法区里静态属性引用的对象(
static字段) - 方法区里常量引用的对象(
static final String,字符串常量池) - 本地方法栈里 JNI 引用的对象
- 被同步锁持有的对象、JVM 内部引用(Class 对象、异常对象等)
这也就解释了为什么静态集合是内存泄漏的重灾区——静态字段是 GC Roots,挂在它上面的对象永远可达。
三种回收算法
标记-清除
先标记所有要回收的对象,标记完统一清除。它是最基础的算法,后面两种都是基于它改进的。
两个缺点:效率低(标记和清除都要遍历全堆);产生内存碎片。碎片太多会导致明明总空闲内存够,但分配一个大对象时找不到连续空间,不得不提前触发 Full GC。
复制
把内存分成两块,只用其中一块。GC 时把存活对象复制到另一块,然后把已用的那块整个清掉。
优点:没有碎片,且复制的时候顺便做了整理(存活对象紧凑排列),分配时可以用指针碰撞(bump the pointer),非常快。缺点:内存利用率只有 50%,而且存活对象多时复制开销大。
所以新生代用它正合适——IBM 的研究表明 98% 的新生代对象"朝生夕死",存活的少,复制成本极低。HotSpot 的实现不是 1:1 分,而是 Eden : Survivor0 : Survivor1 = 8:1:1,每次只用 Eden 加一个 Survivor(共 90%),另一个 Survivor 空着等着接收存活对象,浪费只有 10%。
标记-整理
标记之后,让所有存活对象向内存一端移动,然后清掉边界外的内存。
缺点是要移动对象,必须更新所有指向这些对象的引用,而且移动过程中用户线程必须暂停(STW)。所以它的停顿时间比标记-清除长。但老年代对象存活率高,不适合复制算法,用标记-整理比较划算。
还有个折中做法:CMS 用的就是"标记-清除",平时不管碎片,等碎片实在太多了再做一次带整理的 Full GC(这时候会退化成 Serial Old,非常慢)。
七种收集器
JDK 8 里一共 7 种,分属新生代和老年代:
| 收集器 | 代 | 线程 | 算法 | 特点 |
|---|---|---|---|---|
| Serial | 新 | 单线程 | 复制 | Client 模式默认,简单高效 |
| ParNew | 新 | 多线程 | 复制 | Serial 的多线程版,只有它能配 CMS |
| Parallel Scavenge | 新 | 多线程 | 复制 | 吞吐量优先,JDK 8 默认新生代 |
| Serial Old | 老 | 单线程 | 标记-整理 | CMS 失败时的后备 |
| Parallel Old | 老 | 多线程 | 标记-整理 | 吞吐量优先,JDK 8 默认老年代 |
| CMS | 老 | 并发 | 标记-清除 | 低延迟优先,有碎片问题 |
| G1 | 全部 | 并发 | 标记-整理 + 复制 | 可预测停顿,JDK 9 起默认 |
JDK 8 的默认组合是 Parallel Scavenge + Parallel Old,验证一下:
$ java -XX:+PrintCommandLineFlags -version
-XX:InitialHeapSize=268435456 -XX:MaxHeapSize=4294967296
-XX:+UseParallelGC
-XX:+PrintCommandLineFlags -version
java version "1.8.0_212"
-XX:+UseParallelGC 就是 Parallel Scavenge + Parallel Old 的组合开关。
CMS:为低延迟设计的
CMS(Concurrent Mark Sweep)的目标是最短停顿时间。它把耗时的标记和清除工作跟用户线程并发执行,只在必要的两小段暂停:
- 初始标记(STW,很短):只标记 GC Roots 直接关联的对象
- 并发标记(并发,最长):从初始标记的对象出发,遍历整个对象图
- 重新标记(STW,较短):修正在并发标记期间因用户线程运行而产生变动的那部分标记
- 并发清除(并发):清掉未标记的对象
配法:
-XX:+UseConcMarkSweepGC # 新生代自动用 ParNew
-XX:CMSInitiatingOccupancyFraction=75
-XX:+UseCMSInitiatingOccupancyOnly
-XX:+CMSParallelRemarkEnabled
它有四个公认的问题:
- 对 CPU 敏感。并发阶段要占用一部分线程,默认启动的回收线程数是
(CPU核数 + 3) / 4。4 核机器上占 1 个核,等于 CPU 少了 25%。 - 浮动垃圾。并发清除期间用户线程还在产生新垃圾,这部分只能留到下次 GC("浮动垃圾")。所以要预留空间,
CMSInitiatingOccupancyFraction默认 92% 才触发,太晚了,容易 Concurrent Mode Failure。我们设的 75%。 - Concurrent Mode Failure。如果 GC 期间老年代空间不够了,CMS 会失败并退化成 Serial Old 做 Full GC,停顿时间从几十毫秒变成几秒。我在线上见过一次,停顿了 7.3 秒。
- 内存碎片。标记-清除不整理,碎片多了会触发 Full GC。
-XX:+UseCMSCompactAtFullCollection可以在 Full GC 时整理,但整理意味着更长的停顿。
顺便说一句,CMS 在 JDK 9 已经被标记为 deprecated 了。它的历史使命(在低延迟场景下替代 Parallel Old)基本被 G1 接过去了。
G1:把堆切成小块
G1(Garbage First)的思路和前面几个完全不同。它把整个堆划分成很多个大小相等的 Region(默认 1 MB 到 32 MB,堆大小除以 2048),每个 Region 可以是 Eden、Survivor 或 Old,不要求物理上连续。
这样做的好处是:回收时不必一次性处理整个新生代或老年代,而是选出垃圾最多的那几个 Region 优先回收(这就是 Garbage First 名字的由来),从而做到可预测的停顿时间。
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 目标停顿时间,默认 200ms
-XX:InitiatingHeapOccupancyPercent=45
-XX:G1HeapRegionSize=4m # 一般不用手动设
MaxGCPauseMillis 是个软目标,JVM 会尽力达成但不是硬保证。设得太小(比如 10 毫秒)会让 G1 每次只回收极少的 Region,GC 频率飙升,吞吐反而下降。
G1 有个额外开销:Remembered Set。因为 Region 之间有跨区引用(Old 区的对象引用 Eden 区的对象),G1 需要记录这些引用关系,否则回收某个 Region 时要扫描全堆。Remembered Set 通常占堆的 5% 到 20%,这是用空间换时间。
G1 的回收过程:年轻代 GC(STW,复制存活对象到 Survivor/Old)→ 并发标记 → 混合回收(Mixed GC,同时回收年轻代和部分垃圾多的老年代 Region)。
我们线上的实测
分析了一下我们订单服务的 GC 日志(加了 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/data/logs/gc.log),堆 4 GB,新生代 1.5 GB,运行 24 小时:
2019-11-14T09:23:41.882+0800: 1843.271: [GC (Allocation Failure)
[PSYoungGen: 1228800K->12432K(1376256K)] 2014582K->801214K(4128768K),
0.0421810 secs] [Times: user=0.11 sys=0.01, real=0.04 secs]
2019-11-14T09:23:41.925+0800: 1843.314: [Full GC (Ergonomics)
[PSYoungGen: 12432K->0K(1376256K)]
[ParOldGen: 788782K->412203K(2752512K)] 801214K->412203K(4128768K),
[Metaspace: 98412K->98412K(102400K)], 1.8427160 secs]
[Times: user=5.21 sys=0.03, real=1.84 secs]
用的是 PSYoungGen / ParOldGen,就是默认的 Parallel Scavenge + Parallel Old。24 小时的统计:
| 指标 | Parallel (ParallelGC) | CMS | G1 |
|---|---|---|---|
| Young GC 次数 | 1,842 | 1,903 | 1,731 |
| Young GC 平均耗时 | 42 ms | 38 ms | 31 ms |
| Full GC 次数 | 23 | 3 | 0 |
| Full GC 平均耗时 | 1.84 s | 2.31 s(退化成 Serial Old) | — |
| 最长单次停顿 | 2.4 s | 2.9 s | 184 ms |
| GC 总耗时占比 | 1.43% | 1.87% | 1.21% |
Parallel 的问题很明显:23 次 Full GC,每次接近 2 秒,最长 2.4 秒。对一个在线接口服务来说,2.4 秒的停顿意味着这期间所有请求超时。
换成 G1 之后,24 小时没有一次 Full GC,最长停顿 184 毫秒,而且 GC 总耗时占比还更低。这是我们最后的选择。
-Xms4g -Xmx4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:InitiatingHeapOccupancyPercent=45
-XX:+PrintGCDetails -XX:+PrintGCDateStamps
-Xloggc:/data/logs/gc.log
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump
注意 G1 不要设 -Xmn(新生代大小)。G1 会根据 MaxGCPauseMillis 动态调整新生代的大小,手动设了反而限制了它的调优空间,这是官方文档明确说的。
怎么选
我的判断标准:
| 场景 | 推荐 | 理由 |
|---|---|---|
| 单核、Client 程序、内存小于 100 MB | Serial + Serial Old | 单线程没有切换开销,反而最快 |
| 后台批处理、离线计算、吞吐优先 | Parallel Scavenge + Parallel Old | 吞吐最高,停顿长点无所谓 |
| Web 服务、堆 4~8 GB、要求低延迟 | G1 | 停顿可控,无 Full GC |
| 堆大于 8 GB、延迟要求极高 | G1 调优,或考虑调优 CMS | 一般还得配合扩容和架构拆分 |
CMS 我现在的态度是:新项目不用了。它已经被 JDK 9 标记废弃,配置参数多、毛病也多(碎片、Concurrent Mode Failure),除非你有很成熟的调优经验,否则 G1 是更省心的选择。
另外要认清一件事:换收集器是调优的最后一步。我们这次能降下来,主要不是因为 G1 多先进,而是上面那 23 次 Full GC 里有 18 次是因为一个 static HashMap 缓存泄漏导致的(见我那篇 jmap 排查的笔记)。修了那个 bug 之后,Parallel 的 Full GC 也从 23 次降到 5 次。先解决代码问题,再谈参数。
下篇预告
这篇先把《垃圾回收算法与常见收集器对比》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。