32

Java 垃圾回收权威指北

 5 years ago
source link: http://liujiacai.net/blog/2019/01/09/java-gc-definitive-guide/?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

毫无疑问,GC(垃圾回收) 已经是现代编程语言标配,为了研究这个方向之前曾经写过四篇《深入浅出垃圾回收》博文来介绍其理论,之后也看了不少网络上关于 JDK GC 原理、优化的文章,质量参差不齐,其中理解有误的文字以讹传讹,遍布各地,更是把初学者弄的晕头转向。

不仅仅是个人开发者的文章,一些 大厂的官博 也有错误。

本文在实验+阅读 openjdk 源码的基础上,整理出一份相对来说比较靠谱的资料,供大家参考。

预备知识

术语

为方便理解 GC 算法时,需要先介绍一些常见的名词

  • mutator,应用程序的线程
  • collector,用于进行垃圾回收的线程
  • concurrent(并发),指 collector 与 mutator 可以并发执行
  • parallel(并行),指 collector 是多线程的,可以利用多核 CPU 工作
  • young/old(也称Tenured) 代,根据大多数对象“朝生夕死”的特点,现代 GC 都是分代

一个 gc 算法可以同时具有 concurrent/parallel 的特性,或者只具有一个。

JDK 版本

  • HotSpot 1.8.0_172
  • openjdk8u (changeset: 2298:1c0d5a15ab4c)

为了方便查看当前版本 JVM 支持的选项,建议配置下面这个 alias

alias jflags='java -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal -version'

然后就可以用 jflags | grep XXX 的方式来定位选项与其默认值了。

打印 GC 信息

-verbose:gc
-Xloggc:/data/logs/gc-%t.log
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCCause
-XX:+PrintTenuringDistribution
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=50M
-XX:+PrintPromotionFailure

JDK 中支持的 GC

Java 8 中默认集成了哪些 GC 实现呢? jflags 可以告诉我们

$ jflags |  grep "Use.*GC"
     bool UseAdaptiveGCBoundary                     = false                               {product}
     bool UseAdaptiveSizeDecayMajorGCCost           = true                                {product}
     bool UseAdaptiveSizePolicyWithSystemGC         = false                               {product}
     bool UseAutoGCSelectPolicy                     = false                               {product}
     bool UseConcMarkSweepGC                        = false                               {product}
     bool UseDynamicNumberOfGCThreads               = false                               {product}
     bool UseG1GC                                   = false                               {product}
     bool UseGCLogFileRotation                      = false                               {product}
     bool UseGCOverheadLimit                        = true                                {product}
     bool UseGCTaskAffinity                         = false                               {product}
     bool UseMaximumCompactionOnSystemGC            = true                                {product}
     bool UseParNewGC                               = false                               {product}
     bool UseParallelGC                             = false                               {product}
     bool UseParallelOldGC                          = false                               {product}
     bool UseSerialGC                               = false                               {product}
java version "1.8.0_172"
Java(TM) SE Runtime Environment (build 1.8.0_172-b11)
Java HotSpot(TM) 64-Bit Server VM (build 25.172-b11, mixed mode)

肉眼筛选下,就知道有如下几个相关配置:

  • UseSerialGC
  • UseParNewGC,
  • UseParallelGC
  • UseParallelOldGC
  • UseConcMarkSweepGC
  • UseG1GC

每个配置项都会对应两个 collector ,表示对 young/old 的不同收集方式。而且由于 JVM 不断的演化,不同 collector 的组合方式其实很复杂。而且在 Java 7u4 后,UseParallelGC 与 UseParallelOldGC 其实是等价的,openjdk 中有如下代码:

// hotspot/src/share/vm/runtime/arguments.cpp#set_gc_specific_flags
// Set per-collector flags
if (UseParallelGC || UseParallelOldGC) {
  set_parallel_gc_flags();
} else if (UseConcMarkSweepGC) { // Should be done before ParNew check below
  set_cms_and_parnew_gc_flags();
} else if (UseParNewGC) {  // Skipped if CMS is set above
  set_parnew_gc_flags();
} else if (UseG1GC) {
  set_g1_gc_flags();
}

我们可以用 下面的代码 测试使用不同配置时,young/old 代默认所使用的 collector:

package gc;
// 省略 import 语句
public class WhichGC {
    public static void main(String[] args) {
        try {
            List<GarbageCollectorMXBean> gcMxBeans = ManagementFactory.getGarbageCollectorMXBeans();
            for (GarbageCollectorMXBean gcMxBean : gcMxBeans) {
                System.out.println(gcMxBean.getName());
            }
        } catch (Exception exp) {
            System.err.println(exp);
        }
    }
}
$ java gc.WhichGC  # 两个输出分别表示 young/old 代的 collector
PS Scavenge
PS MarkSweep

$ java -XX:+UseSerialGC gc.WhichGC
Copy
MarkSweepCompact

$ java -XX:+UseParNewGC gc.WhichGC # 注意提示
Java HotSpot(TM) 64-Bit Server VM warning: Using the ParNew young collector with the Serial old collector is deprecated and will likely be removed in a future release
ParNew
MarkSweepCompact

$ java -XX:+UseParallelGC gc.WhichGC
PS Scavenge
PS MarkSweep # 虽然名为 MarkSweep,但其实现是 mark-sweep-compact

$ java -XX:+UseParallelOldGC gc.WhichGC # 与上面输出一致,不加 flag 时这样同样的输出
PS Scavenge
PS MarkSweep

$ java -XX:+UseConcMarkSweepGC gc.WhichGC # ParNew 中 Par 表示 parallel,表明采用并行方式收集 young 代
ParNew
ConcurrentMarkSweep  # 注意这里没有 compact 过程,也就是说 CMS 的 old 可能会产生碎片

$ java -XX:+UseG1GC gc.WhichGC
G1 Young Generation
G1 Old Generation

PS 开头的系列 collector 是 Java5u6 开始引入的。按照 R 大的说法 ,这之前的 collector 都是在一个框架内开发的,所以 young/old 代的 collector 可以任意搭配,但 PS 系列与后来的 G1 不是在这个框架内的,所以只能单独使用。

使用 UseSerialGC 时 young 代的 collector 是 Copy,这是单线程的,PS Scavenge 与 ParNew 分别对其并行化,至于这两个并行 young 代 collector 的区别呢?这里再引用 R 大的回复

  1. PS以前是广度优先顺序来遍历对象图的,JDK6的时候改为默认用深度优先顺序遍历,并留有一个UseDepthFirstScavengeOrder参数来选择是用深度还是广度优先。在JDK6u18之后这个参数被去掉,PS变为只用深度优先遍历。ParNew则是一直都只用广度优先顺序来遍历
  2. PS完整实现了adaptive size policy,而ParNew及“分代式GC框架”内的其它GC都没有实现完(倒不是不能实现,就是麻烦+没人力资源去做)。所以千万千万别在用ParNew+CMS的组合下用UseAdaptiveSizePolicy,请只在使用UseParallelGC或UseParallelOldGC的时候用它。
  3. 由于在“分代式GC框架”内,ParNew可以跟CMS搭配使用,而ParallelScavenge不能。当时ParNew GC被从Exact VM移植到HotSpot VM的最大原因就是为了跟CMS搭配使用。
  4. 在PS成为主要的throughput GC之后,它还实现了针对NUMA的优化;而ParNew一直没有得到NUMA优化的实现。

如果你对上面所说的 mark/sweep/compact 这些名词不了解,建议参考下面这篇文章:

其实原理很简单,和我们整理抽屉差不多,找出没用的垃圾,丢出去,然后把剩下的堆一边去。但是别忘了

The evil always comes from details!

怎么定义「没用」?丢垃圾时还允不允许同时向抽屉里放新东西?如果允许放,怎么区别出来,以防止被误丢?抽屉小时,一个人整理还算快,如果抽屉很大,多个人怎么协作?

核心流程指北

ParallelGC

SerialGC 采用的收集方式十分简单,没有并行、并发,一般用在资源有限的设备中。由于其简单,对其也没什么好说的,毕竟也没怎么用过 :-)

ParallelGC 相比之下,使用多线程来回收,这就有些意思了,比如

  • 多个GC线程如何实现同步,需要注意一点,ParallelGC 运行时会 STW,因此不存在与 mutator 同步问题
  • 回收时,并行度如何选择(也就是 GC 对应用本身的 overhead)

不过比较可惜,cpp 在大二写完几个 console 应用后,就一直没怎么用过了,因为也就没发去探究多个 GC 线程如何实现同步,大略扫一下 parNewGeneration.cpp 这个文件,大概是这样的:

每个 GC 线程对应一个 queue(叫 ObjToScanQueue),然后还支持不同 GC 线程间 steal,保证充分利用 cpu

// ParNewGeneration 构造方法
for (uint i1 = 0; i1 < ParallelGCThreads; i1++) {
  ObjToScanQueue *q = new ObjToScanQueue();
  guarantee(q != NULL, "work_queue Allocation failure.");
  _task_queues->register_queue(i1, q);
}
// do_void 方法
while (true) {

  ......
  // We have no local work, attempt to steal from other threads.

  // attempt to steal work from promoted.
  if (task_queues()->steal(par_scan_state()->thread_num(),
                           par_scan_state()->hash_seed(),
                           obj_to_scan)) {
    bool res = work_q->push(obj_to_scan);
    assert(res, "Empty queue should have room for a push.");

    //   if successful, goto Start.
    continue;

    // try global overflow list.
  } else if (par_gen()->take_from_overflow_list(par_scan_state())) {
    continue;
  }
  .......
}

下面还是重点说一下我们开发者能控制的选项,

  • -XX:MaxGCPauseMillis=<N> 应用停顿(STW)的的最大时间
  • -XX:GCTimeRatio=<N> GC 时间占整个应用的占比,默认 99。需要注意的是,它是这么用的 1/(1+N) ,即默认 GC 占应用时间 1%。这么说来这个选项的意思貌似正好反了!
    其实不仅仅是这个,类似的还有 NewRatio SurvivorRatio ,喜欢八卦的可以看看 《我可能在跑一个假GC》

当然,上面两个指标是软限制,GC 会采用后面提到的自适应策略(Ergonomics)来调整 young/old 代大小来满足。

Ergonomics

每次 gc 后,会记录一些统计信息,比如 pause time,然后根据这些信息来决定

  1. 目标是否满足
  2. 是否需要调整代大小

可以通过 -XX:AdaptiveSizePolicyOutputInterval=N 来打印出每次的调整,N 表示每隔 N 次 GC 打印。

默认情况下,一个代增长或缩小是按照固定百分比,这样有助于达到指定大小。默认增加以 20% 的速率,缩小以 5%。也可以自己设定

-XX:YoungGenerationSizeIncrement=<Y>
-XX:TenuredGenerationSizeIncrement=<T>
-XX:AdaptiveSizeDecrementScaleFactor=<D>
# 如果增长的增量是 X,那么减少的减量则为 X/D

当然,一般情况下是不需要自己设置这三个值的,除非你有明确理由。

使用场景

ParallelGC 另一个名字就表明了它的用途:吞吐量 collector。主要用在对延迟要求低,更看重吞吐量的应用上。

我们公司的数据导入导出、跑报表的定时任务,用的就是这个 GC。(能提供数据导入导出的都是良心公司呀!)

一般利用自适应策略就能满足需求。线上的日志大概这样子:

2018-12-27T22:14:19.006+0800: 5433.841: [GC (Allocation Failure) [PSYoungGen: 606785K->3041K(656896K)] 746943K->143356K(2055168K), 0.0157837 secs] [Times: user=0.03 sys=0.01, real=0.02 secs]
    UseAdaptiveSizePolicy actions to meet  *** reduced footprint ***
                       GC overhead (%)
    Young generation:        0.02         (attempted to shrink)
    Tenured generation:      0.00         (attempted to shrink)
    Tenuring threshold:    (attempted to decrease to balance GC costs) = 1
2018-12-27T22:21:36.581+0800: 5871.417: [GC (Allocation Failure) [PSYoungGen: 615905K->3089K(654848K)] 756220K->143504K(2053120K), 0.0157796 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
    UseAdaptiveSizePolicy actions to meet  *** reduced footprint ***
                       GC overhead (%)
    Young generation:        0.01         (attempted to shrink)
    Tenured generation:      0.00         (attempted to shrink)
    Tenuring threshold:    (attempted to decrease to balance GC costs) = 1
2018-12-27T22:28:51.669+0800: 6306.505: [GC (Allocation Failure) [PSYoungGen: 615953K->3089K(660992K)] 756368K->143664K(2059264K), 0.0178418 secs] [Times: user=0.03 sys=0.01, real=0.02 secs]
    UseAdaptiveSizePolicy actions to meet  *** reduced footprint ***
                       GC overhead (%)
    Young generation:        0.01         (attempted to shrink)
    Tenured generation:      0.00         (attempted to shrink)
    Tenuring threshold:    (attempted to decrease to balance GC costs) = 1
2018-12-27T22:36:17.738+0800: 6752.573: [GC (Allocation Failure) [PSYoungGen: 624145K->2896K(658944K)] 764720K->143576K(2057216K), 0.0144179 secs] [Times: user=0.02 sys=0.01, real=0.01 secs]
    UseAdaptiveSizePolicy actions to meet  *** reduced footprint ***
                       GC overhead (%)
    Young generation:        0.01         (attempted to shrink)
    Tenured generation:      0.00         (attempted to shrink)
    Tenuring threshold:    (attempted to decrease to balance GC costs) = 1
2018-12-27T22:43:40.208+0800: 7195.043: [GC (Allocation Failure) [PSYoungGen: 623952K->2976K(665088K)] 764632K->143720K(2063360K), 0.0135656 secs] [Times: user=0.03 sys=0.01, real=0.02 secs]
    UseAdaptiveSizePolicy actions to meet  *** reduced footprint ***
                       GC overhead (%)
    Young generation:        0.01         (attempted to shrink)
    Tenured generation:      0.00         (attempted to shrink)
    Tenuring threshold:    (attempted to decrease to balance GC costs) = 1
2018-12-27T22:48:59.110+0800: 7513.945: [GC (Allocation Failure) [PSYoungGen: 632224K->5393K(663040K)] 772968K->146241K(2061312K), 0.0230613 secs] [Times: user=0.05 sys=0.01, real=0.02 secs]
    UseAdaptiveSizePolicy actions to meet  *** reduced footprint ***
                       GC overhead (%)
    Young generation:        0.01         (attempted to shrink)
    Tenured generation:      0.00         (attempted to shrink)
    Tenuring threshold:    (attempted to decrease to balance GC costs) = 1
2018-12-27T22:54:05.871+0800: 7820.706: [GC (Allocation Failure) [PSYoungGen: 634641K->4785K(669696K)] 775489K->147601K(2067968K), 0.0173448 secs] [Times: user=0.04 sys=0.01, real=0.02 secs]
    UseAdaptiveSizePolicy actions to meet  *** reduced footprint ***
                       GC overhead (%)
    Young generation:        0.01         (attempted to shrink)
    Tenured generation:      0.00         (attempted to shrink)
    Tenuring threshold:    (attempted to decrease to balance GC costs) = 1

CMS

CMS 相比于 ParallelGC,支持并发式的回收,虽然个别环节还是需要 STW,但相比之前已经小了很多;另一点不同是 old 代在 sweep 后,没有 compact 过程,而是通过 freelist 来将空闲地址串起来。CMS 具体流程还是参考下面的文章:

上述文章会针对 gc 日志里面的每行含义做解释,务必弄清楚每一个数字含义,这是今后调试优化的基础。网站找了个 比较详细的图 供大家参考:

M3aiUf3.jpg!web

之前在有赞的同事阿杜写过一篇 《不可错过的CMS学习笔记》 推荐大家看看,主要是文章的思路比较欣赏,带着问题去探索。重申下 CMS 的特点:

  • CMS 作用于 old 区,与 mutator 并发执行(因为是多线程的,所以也是并行的);默认与 young 代 ParNew 算法一起工作

下面重点说一下 CMS 中误传最广的 CMF 与内存碎片问题。

Concurrent mode failure

在每次 young gc 开始前,collector 都需要确保 old 代有足够的空间来容纳新晋级的对象(通过之前GC的统计估计),如果判断不足,或者当前判断足够,但是真正晋级对象时空间不够了(即发生 Promotion failure),那么就会发生 Concurrent mode failure(后面简写 CMF),CMF 发生时,不一定会进行 Full GC,而是这样的:

如果这时 CMS 会正在运行,则会被中断,然后根据 UseCMSCompactAtFullCollection、CMSFullGCsBeforeCompaction 和当前收集状态去决定后面的行为

有两种选择:

  1. 使用跟Serial Old GC一样的LISP2算法的mark-compact来做 Full GC,或
  2. 用CMS自己的mark-sweep来做不并发的(串行的)old generation GC (这种串行的模式在 openjdk 中称为 foreground collector,与此对应,并发模型的 CMS 称为 background collector)

UseCMSCompactAtFullCollection默认为true,CMSFullGCsBeforeCompaction默认是0,这样的组合保证CMS默认不使用foreground collector,而是用Serial Old GC的方式来进行 Full GC,而且在 JDK9 中,彻底去掉了这两个参数以及 foreground GC 模式,具体见: JDK-8010202: Remove CMS foreground collection ,所以这两个参数就不需要再去用了。

这里还需要注意,上述两个备选策略的异同,它们所采用的算法与作用范围均不同:

  1. Serial Old GC的算法是mark-compact(也可以叫做mark-sweep-compact,但要注意它不是“mark-sweep”)。具体算法名是LISP2。它收集的范围是整个GC堆,包括Java heap的young generation和old generation,以及non-Java heap的permanent generation。因而其名 Full GC
  2. CMS的foreground collector的算法就是普通的mark-sweep。它收集的范围只是CMS的old generation,而不包括其它generation。因而它在HotSpot VM里不叫做Full GC

解决 CMF 的方式,一般是尽早执行 CMS,可以通过下面两个参数设置:

-XX:CMSInitiatingOccupancyFraction=60
-XX:+UseCMSInitiatingOccupancyOnly

上述两个参数缺一不可,第一个表示 old 区占用量超过 60% 时开始执行 CMS,第二个参数禁用掉 JVM 的自适应策略,如果不设置这个 JVM 可能会忽略第一个参数。

上述关于 CMF 解释主要参考

内存碎片

Promotion failure 一般是由于 heap 内存碎片过多导致检测空间足够,但是真正晋级时却没有足够连续的空间,监控 old 代碎片可以用下面的选项

-XX:+PrintGCDetails
-XX:+PrintPromotionFailure
-XX:PrintFLSStatistics=1

这时的 gc 日志大致是这样的

592.079: [ParNew (0: promotion failure size = 2698)  (promotion failed): 135865K->134943K(138240K), 0.1433555 secs]
Statistics for BinaryTreeDictionary:
------------------------------------
Total Free Space: 40115394
Max   Chunk Size: 38808526
Number of Blocks: 1360
Av.  Block  Size: 29496
Tree      Height: 22

重点是 Max Chunk Size 这个参数,如果这个值一直在减少,那么说明碎片问题再加剧。解决碎片问题可以按照下面步骤:

  1. 尽可能提供较大的 old 空间,但是最好不要超过 32G, 超过了就没法用压缩指针了
  2. 尽早执行 CMS,即修改 initiating occupancy 参数
  3. 减少 PLAB,我具体还没试过,可参考 Java GC, HotSpot’s CMS promotion buffers 这篇文章
  4. 应用尽量不要去分配巨型对象

调优

说到优化,让很多人望而却步,一方便有人不断在说“不要过早优化”,另一方面在真正有问题时,不知道如何入手。这里说个人的一些经验供大家参考。

既然提到 GC 优化,首先要明确衡量 GC 的几个指标,LinkedIn 在这方面值得借鉴,在 Tuning Java Garbage Collection for Web Services 提出了从 gc 日志中可以获知的 5 个指标:

  1. Allocation Rate: the size of the young generation divided by the time between young generation collections
  2. Promotion Rate: the change in usage of the old gen over time (excluding collections)
  3. Survivor Death Ratio: when looking at a log, the size of survivors in age N divided by the size of survivors in age N-1 in the previous collection
  4. Old Gen collection times: the total time between a CMS-initial-mark and the next CMS-concurrent-reset. You’ll want both your ‘normal’ and the maximum observed
  5. Young Gen collection times: both normal and maximum. These are just the “total collection time” entries in the logs Old Gen Buffer:
    the promotion rate*the maximum Old Gen collection time*(1 + a little bit)
    

直接从纯文本的 gc 日志中得出这 5 项指标比较困难,还好有个比较好用的开源工具 gcplot ,借助 docker,一行命令即可启动

docker run -d -p 8080:80 gcplot/gcplot

实战

利用 gcplot,我对公司内部 API 服务进行了一次优化,效果较为明显:

优化前的配置:Xmx/Xms 均为 4G,CMSInitiatingOccupancyFraction=60,下面是使用 gcplot 得到的一些数据

Percentiles STW Pause (ms) 50% 22.203 90% 32.872 95% 40.255 99% 76.724 99.9% 317.584
  • STW Pause per Minute: 3.396 secs
  • STW Events per Minute: 133
Promoted Total 17.313 GB Promotion Rate (MB/Sec) 5.99 Allocated Total 5.053 TB Allocation Rate (MB/Sec) 1273.73

优化后的配置:Xmx/Xms 均为 4G, NewRatio 为 1, CMSInitiatingOccupancyFraction=80。

这么修改主要是增加 young 区空间,因为对于 Web 服务来说,除了一些 cache 外,没什么常驻内存的对象;通过把 OccupancyFraction 调大,延迟 CMS 发生频率,还是基于前面的推论,大多数对象不会晋级到 old 代,所以发生碎片的概率也不会怎么大。下面是优化后的相关参数,也证明了上面的猜想

percentiles STW pause(ms) 50% 19.75 90% 30.334 95% 35.441 99% 53.5 99.9% 120.008
  • STW Pause per Minute: 826.607 ms
  • STW Events per Minute: 38
Promoted Total 6.182 GB Promotion Rate (MB/Sec) 0.29 Allocated Total 28.254 TB Allocation Rate (MB/Sec) 1121.29

参考资料

虽然本文一开始指出 LinkedIn 文章中存在理解误差,但是那篇文章的思路还是值得解决,下面再次给出链接

总结

上面基本把 ParallelGC 与 CMS 核心点过了一遍,然后顺带介绍了下优化,主要还是熟悉 GC 日志中的每个指标含义,理解透后再去决定是否需要优化。关于 G1 本文没有过多介绍,主要是用的确实不多,后面会尝试把服务升级到 G1 后再来写写它。

本文一开始就说网络上关于 GC 的误解很多,本文可能也是这样的,虽然我已经尽可能保证“正确”,但还是需要大家带着辩证的眼光来看。元芳,你怎么看?

扩展阅读


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK