Administrator
发布于 2019-11-15 / 4845 阅读
70

垃圾回收算法与常见收集器对比

面试官问"你们生产用哪个收集器",我答不上来

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)的目标是最短停顿时间。它把耗时的标记和清除工作跟用户线程并发执行,只在必要的两小段暂停:

  1. 初始标记(STW,很短):只标记 GC Roots 直接关联的对象
  2. 并发标记(并发,最长):从初始标记的对象出发,遍历整个对象图
  3. 重新标记(STW,较短):修正在并发标记期间因用户线程运行而产生变动的那部分标记
  4. 并发清除(并发):清掉未标记的对象

配法:

-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)CMSG1
Young GC 次数1,8421,9031,731
Young GC 平均耗时42 ms38 ms31 ms
Full GC 次数2330
Full GC 平均耗时1.84 s2.31 s(退化成 Serial Old)
最长单次停顿2.4 s2.9 s184 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 MBSerial + 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 次。先解决代码问题,再谈参数。

下篇预告

这篇先把《垃圾回收算法与常见收集器对比》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考