1. 背景
最近给代码重构,压测的时候,没有配置GC的相关参数,结果发生了比较严重的Full GC停顿现象。
为了解决这个问题,我dump了一份堆文件,并使用jmap
查看堆内存的实际占用,发现了一些现象:
- 大部分的内存都被字符串占去,即
String
和char[]
(目前是JDK 8) - 分析得到的活跃对象很少
这提示了我:程序实际没有内存泄漏,只是GC配置错了。
实际上,压测的时候,默认的GC是Parallel GC(ParNew + Parallel Old)。
总所周知,Java GC是分代的。当压力上来时,产生大量的字符串,有的字符串:
- 可能因为年轻代空间不足(如Eden区不足、GC后Survivor区不足),而直接进入老年代
- 可能因为多次新生代Minor GC后,到达一定年龄,进入老年代
因此会有大量字符串进入老年代,在Minor GC时无法得到回收,只能等到Full GC时才能被回收(而Parallel Old会导致STW)。
因此,我将GC算法换成G1(也是JDK 9以后的默认GC算法),问题得到解决,程序在高压测试下没有出现不可接收的停顿,GC回收占用的CPU比例也在0.5%以下。
G1是面向服务器的GC,不仅能高概率满足停顿要求,而且也具备高吞吐的特征。主要特征为:
- 和CMS一样,GC能和应用线程并发执行
- 内存区间紧凑
- 可预测的GC停顿时间,且不长
- 不会牺牲太多吞吐量
G1目标用于取代CMS,它相比CMS有下面的优势:
- G1能整理内存分配,处理和解决内存碎片问题
- G1的停顿时间更易估计,允许设置停顿时长限制
不过说明G1前,首先回顾CMS,然后再说其替换者G1。
2. 回顾:CMS
CMS用于老年代的回收,其堆内存的布局也和上图一样。它绝大部分GC工作都能和应用线程一起并发执行,以最小化暂停时间。但它不会进行内存整理,因此会有碎片问题。
年轻代通常还是Parallel GC的ParNew
下面说明一下CMS的具体步骤。
2.1. 初始标记
这一步会STW。
这一步会将存活的老年代对象进行”标记“,包括可以从年轻代对象里可达的。这一步停顿时间大约在一次Minor GC时长。
2.2. 并发标记
这一步,GC线程和应用线程一起执行,从第1步标记的对象开始,遍历整个老年代,构建一个可达对象图。
CMS还有一个调整器(Mutator),它在GC的第2、3、5步执行,任何在老年代被分配的对象(包括被提升成老年代的对象)都会被标记成”可达“/”存活“的。
2.3. 重新标记
这一步也会STW。
这一步是为了修正第2步的构建结果,对标记进行更新,避免遗漏掉对象。
2.4. 并发清理
这一步,GC也可以和应用线程一起执行。
它根据前3步得到的可达对象图,得到不可达的对象。这些不可达的对象(即”死掉“的对象)会被回收,具体的方法是将其分配到的空间重新添加到free list中。
而可达的对象是不会拷贝/移动,因此这里不可避免地产生碎片问题。
Parallel Old会整理整个堆
2.5. 重置
这一步进行一些数据结构的清理,准备下一轮的回收。
3. G1垃圾回收
3.1. 概述
G1回收的方式和CMS相似,不过也有一些不一样的地方:
- 内存分配结构不同,G1基于等大小的分区,回收时优先回收垃圾多的分区
- G1使用暂停预测模型来达到用户定义的暂停时间,并决定回收哪些分区,以及回收的数量
- G1回收分区时,使用”转移“的方式回收,并整理内存碎片(可参考Survivor区的回收,很快)
3.2. G1的堆内存结构
不同于CMS和其它以前的GC,G1使用了不同的堆内存分配策略:
- 它依旧对堆进行分代
- 它将整个堆分成多个大小固定的区域,官方叫Region,范围可以是1~32MB
- 它对巨型对象(对象超过分区一半大小)单独分区管理,直接分属老年代,且分区连续
3.3. G1的内存占用(Footprint)
G1可能比Parallel Old或CMS更吃内存,这是因为G1需要维护额外的accounting数据结构,如:
-
Remember Sets (RSets):每个分区都维护一个,记录并跟踪其它Region指向该Region中对象的引用(影响小于5%)
每个Region会被划分成多个Card。
而RSet是一个散列表,键是其它Region的地址,值是该Region上的对象(持有本Region引用)所在的Card Index集合。
例子:若Region 1的对象
A
和对象B
,引用了Region 2的对象C
,对象A
和B
在第1和第2个Card上,那么Region是的RSet的值是:{ region_1: [1, 2] }
。 -
Collection Sets (CSets):一个集合,记录了需要执行回收的分区。GC时,只需遍历CSets里记录的分区的RSets,就能构建出跨分区的引用关系图
3.4. 年轻代GC
年轻代GC和之前的很像,它将存活的对象(Eden + Survivor)”转移“到Survivor区(这个区原本是未分配的,转移/拷贝完,将其映射成Survivor),”转移“即带来一次拷贝,也带来一次整理。若年龄超过阈值,则将对象转移到老年代。
年轻代GC使用多线程进行回收,不过它会STW。
另外,G1和其它算法不同的是,它会利用统计信息和配置信息,动态调整Eden区和Survivor区的大小,从而达到可控停顿和不牺牲吞吐的要求。
总结:
- 年轻代GC使用”转移“/拷贝的方式回收,将年轻代对象拷贝到Survivor/老年代,不会产生碎片
- 年轻代GC使用多线程回收
- 年轻代GC会STW
- 年轻代GC可动态调整Eden和Survivor区的大小,以满足性能需要
3.5. 老年代GC
G1老年代的回收和CMS非常相似。
a) 初始化标记
这一步会STW,且它会稍带在一次年轻代GC里,日志会打 GC pause (young)(inital-mark)
。
这一步用于标记有哪些年轻代的分区引用了老年代对象。这些分区被称为根分区(Root Region)。
由于该步骤在年轻代GC中稍带,因此只需扫描Survivor区。
b) 扫描根分区
这一步可以和应用线程一起运行。
该步骤从根分区(第一步找到的)开始,扫描这些分区中引用的老年代对象。
注意,老年代GC可能伴随着年轻代GC,这一步必须在年轻代GC发生前完成。
c) 并发标记
这一步也可以和应用线程一起运行。
和CMS类似,它使用多线程扫描整个堆中存活的对象,构建可达对象图。
注意,这一步可被年轻代GC打断。
d) 再次标记
这一步会STW。
也和CMS类似,它用于修正第3步的结果,完成对堆内存活对象的标记。不过,这一步的修正使用了SATB算法,比CMS快非常多,具体可参考一些论文。
e) 拷贝(转移)和清理
拷贝(转移)就是执行回收内存的操作,即使用”转移“/拷贝的算法,把活跃对象拷贝到一个空闲分区中(清掉原分区放置在清理步骤进行)。这类方法整理了内存,不会产生碎片问题。
拷贝本身是多线程执行的,但是它会STW。
拷贝(转移)可同时对年轻代和老年代进行回收。若拷贝本身只来源于年轻代,日志会打[GC pause (young)]
;若拷贝涉及到老年代,那么日志会打[GC pause (mixed)]
。
G1会优先选择”活跃度“最低的几个区域进行回收,因为这类区域回收速度往往最快。
而活跃度会在程序运行时就会被计算出来。
而清理主要分为3步:
- 执行一些统计操作,如活跃对象、完全空闲分区等等(STW)
- 擦写RSets(STW)
- 重置空分区,并将其归还到free-list(可和应用线程并发执行)
4. 关于G1的Best Practice
4.1. 不显式设置年轻代大小
不要通过-Xmn
或 -XX:NewRatio
显式指定年轻代大小。
因为G1在年轻代GC时,会通过统计信息动态调整年轻代大小,显式指定反而会干扰G1的默认行为,包括:
- G1不再关心暂停时间指标
- G1不会动态调整年轻代空间大小
4.2. 注意停顿时间指标值
可通过XX:MaxGCPauseMillis
设置停顿时间的指标值,但是这个指标值不应该设置成平均响应时间,而最好设置成90%或更大的目标时间。
另外,这个暂停时间只是一个目标,并不能完全满足。
4.3. 避免拷贝/转移失败
G1的内存回收/对象晋升都是用拷贝算法,因此必须要有空闲的分区。
当没有空闲分区,且堆内存不能扩大(被限制死),那么日志会报如to-space overflow
,evacuation failure
, to-space exhausted
, promotion failure
等字眼。最终,G1往往会回退触发一次Full GC(在JDK9及以前是单线程,10及以后是多线程),这会STW,开销非常大。
实际上,G1本身是不包含Full GC,只有young和mixed GC,它们的停顿都是可控的,只有在这2个GC都失败情况下,才会回退触发Full GC。
为了避免这类事情的出现,可以考虑:
- 增加堆内存大小,如
- 增加
-Xmx
的配置大小 - 增加
-XX:G1ReservePercent
值,默认10,该设置作为空闲空间的预留内存百分比,增加或减少百分比时,请确保对总的 Java 堆调整相同的量
- 增加
- 更早地启动标记周期,如配置
-XX:InitiatingHeapOccupancyPercent
到更低的值(即堆达到某个更低的使用率,触发一次标记周期) - 增加标记线程的数量,可通过
-XX:ConcGCThreads
配置
若莫名奇妙发生Full GC,那么可以怀疑是巨型对象造成的,可通过dump文件来分析。
5. 总结与比较
比较:
- Parallel GC: Parallel GC能整理空间,但是它把老年代当成整体看待;而G1通过分区,将整理和收集任务分散。G1在收集老年代时,有更低的停顿,吞吐也降低较少。
- CMS: CMS和G1的老年代收集步骤很类似,但CMS不整理内存,有碎片问题,可能导致未来的Full GC,而G1不会出现。
- G1由于是并发执行且需要维护更多的统计数据结构,开销可能比其它收集器来的更大,对吞吐量影响也会更大。
总之,G1为用户的应用程序的提供一个低GC延时和大内存GC的解决方案,并且在下面特征中,使用G1能带来很好的效果:
- Full GC时间很长,或频繁
- 对象分配很频繁,或对象升级到老年代很频繁(第1节的例子就是这个)
- 期望有可控的GC暂停时间
不过使用CMS或Parallel GC没出现长时间GC,程序运行很顺畅,那么没必要切换到G1,正如上面所述,G1的特性会带来额外的开销。