问题现象
当前项目是基于GemFire集群开发,然而我们偶尔会遇到一个节点掉出集群的情况。在分析问题过程中,我们发现在该节点(N1)掉出去之前发生了如下事件。首先,N1最后的log时间在2015/07/23 06:25:35.544,并且直到2015/07/23 06:31:44.624( 6分钟以后)在开始出现下一个log,接收到Primary Locator发出的机群中新的节点视图,处理Primary Locator给他的消息说它“Failed to respond within ack-wait-threshold”。此时它想再向N2发起连接并发送数据时,虽然重连成功,但是N2已经认为它已经不在集群中了,因而拒绝它的请求,所以它自己退出了集群:
而在这6分钟期间,N2和Primary Locator之间进行了如下交互:
在2015/07/23 06:27:07.587,Primary Locator发现N1在12,437ms内没有相应它的ACK(view_ack_collection_timeout,默认12,437ms):
问题分析
开始我们一直觉得这是网络的问题,因为N1不可能在3分钟内没有给N2响应,并且当Locator向它询问are-you-dead的时候它还是没有相应,而在当前这个case下,它竟然有6分钟没有相应,那么长的时间里这个节点会处理什么事情呢?因而我们用了各种工具查网络在出问题的那个点上的情况,有没有中断、包丢失之类的,然而一无所获,直到我们从GC的日志中找到了这句话:要不是亲眼所见,我真不敢相信GC会用掉6分多钟的时间!!自己之前也有看过GC相关的书籍,但是一直觉得GC很快(估计是看的不够多),因而并没有引起很大的重视,然后我去翻了一下以前GC的日志,发现我们它不是一个可以随便忽略的操作,即使是ParNew GC都有可能花费7s的时间!!而且平时也一般都在二三十毫秒的数量级(这是我们的测试环境,生产环境的性能要好很多,基本在几个毫秒的级别)。
问题解决
要减少GC的停顿时间,我觉得可以从三个方向入手:
- 最直观的当然是减少一个进程的总内存大小。
- 调节JVM的参数,减少GC的停顿时间,避免Full GC。
- 减少类的创建,进而减少GC次数,避免Full GC。
然而在我们的系统中GC花费那么长的时间,当然是和我们的内存大小设置有关系,因为我们的数据节点每个内存大小10G,其中Young Gen:1G,Perm Gen:200M,Old Gen:~9G,因而做一次Full GC当然是比较费时间的。所以一种最简单的做法当然是减少内存,然而减少一个数据节点的内存意味着我们需要增加节点的个数,不然无法保证GemFire集群能存下所有的数据,而增加节点个数又会带来新的问题,如增加运帷难度,节点出错的概率提高,提高集群的通信成本,增加MetaData的存储空间等,因而这个目前不是好的方向。所以我们只能寻求2和3的方案。
调节JVM的参数
当前GemFire节点退出集群问题的直接原因是因为ParNew GC promotion失败,从而引起CMS GC,然而CMS GC又产生concurrent mode failure,进而引起STW(Stop The World)的Full GC引起的,因而比较直接的方案就是调节JVM的参数,以减少GC停顿时间,并且尽量避免Full GC,即方案2。对JVM参数调节首先可以从选择不同的GC收集器开始,如对Young Gen的Serial、ParNew、Parallel Scavenge,对Old Gen的CMS、Serial Old(MSC)、Parallel Old,或者是直接用G1,我们目前使用ParNew+CMS,已经使用多年,而且老板属于保守型,这条路也基本走不通,因而这里只介绍ParNew+CMS的GC。ParNew收集器调节
对ParNew收集器,它是Serial收集器的多线程版本,使用复制算法,由于每次GC的存活对象很少,因而它使用两个Survivor和一个Eden实现,默认情况下Survivor和Eden的比例是1:8,因而这里可以调节的参数有Young Gen的总大小(-XX:NewSize=1024M -XX:MaxNewSize=1024M),Survivor和Eden的比例(-XX:SurvivorRatio=8),收集线程数(-XX:ParallelGCThreads,默认与CPU数量相同),对对象大小超过一定数值的直接晋升到老年代(-XX:PretenureSizeThreshold,无默认值),设置晋升到老年代的年龄(-XX:MaxTenuringThreshold,默认15),开启Promotion失败的担保(-XX:+HandlePromotionFailure,1.6默认开启)。在我们的设置中,Young Gen的总大小和最大大小设置为一样(1G)以减少动态分配的开销,然而在观察中发现102.4M的Survivor大小有些时候并不足以存放所有的存活对象,因而可以适当增加Young Gen的大小:在观察ParNew GC的日志后,我发现大部分情况user和real的时间保持在1:1到5:1之间,然而其实我们有8个CPU,即8个CPU并没有很好的发生作用,反而因为线程交互开销而影响了它的性能,因而可以设置4个线程来回收;另外,通过日志我还发现有些时候real的时间要远远超过user的时间:
2015-07-23T05:47:05.774-0400: [GC [ParNew: 943744K-> 75951K(943744K), 7.4119250 secs] 8202594K->7383241K(10380928K), 7.4120460 secs] [Times: user=0.31 sys=0.06, real= 7.41 secs]
这里大部分Survivor不足以存放所有的存活对象,然而并不是所有的,如第一条就不是,对这个我一直很纳闷,在网上也没有找到非常可能的答案,但是大概 这个答案看起来比较靠谱,简单来说,就是当时有很多page faults,这个也符合我们当时的情况,因为这台机器原本内存不是那么足,因而偶尔会出现内存swap,而且在这种情况下,所有的sys时间相对都比较长,也是一个佐证;另外也有人解释说ParNew在 Stop-The-World之前要确保所有JNI线程都暂停下来,因而需要等待到达safepoint,这个貌似不太像我们遇到的情况,因为我们没有使用外部的JNI,如果内部自己的JNI应该不至于有那么大的影响(但是这个点正好说明我们使用JNI的时候要非常小心,如果有函数运行时间太长会影响JVM的稳定性,甚至让JVM没有响应,因为它在GC的时候要等这个长时间的JNI方法结束,或到达safepoint。 更正: 这里对safepoint有这样的解释: A point during program execution at which all GC roots are known and all heap object contents are consistent. From a global point of view, all threads must block at a safepoint before the GC can run. (As a special case, threads running JNI code can continue to run, because they use only handles. During a safepoint they must block instead of loading the contents of the handle.) From a local point of view, a safepoint is a distinguished point in a block of code where the executing thread may block for the GC. Most call sites qualify as safepoints. There are strong invariants which hold true at every safepoint, which may be disregarded at non-safepoints. Both compiled Java code and C/C++ code be optimized between safepoints, but less so across safepoints. The JIT compiler emits a GC map at each safepoint. C/C++ code in the VM uses stylized macro-based conventions (e.g., TRAPS) to mark potential safepoints.,所以看起来这个论断是有问题的)。ParNew暂停的时间都比较短,而且在大部分情况下,或者在其他内存够用的集群中我们没有遇到那么久的GC时间,因而基本不需要过多调节,所以其他都保持了它的默认值。
更正:前几天我们再次发现ParNew GC用去186.63s:
2015-08-07T14:57:22.317+0100: [GC[ParNew: 845794K->88289K(943744K), 186.6537260 secs] 6808480K->6087745K(10380928K), 186.6540490 secs][Times: user=1703.17 sys=194.13, real=186.63 secs]
通过一些系统工具,我们发现在问题发生的时候,当时那台机器有26%左右的Swap内存,并且有很高的page fault,因而我们基本能肯定这是内存数据被换出到Swap然后在GC的时候产生page fault引起的。并且在 这里还提及到了swap中每4KB数据会增加10ms的GC时间:
A very high sys time suggests the OS is struggling with the application. I would make sure there is no chance the application has been partially swapped to disk as it must be completely in main memory for the GC to run is a reasonable time. Every 4 KB swapped to disk and add 10 ms to the GC time.
因而我们基本能肯定发生那么长的时间就是因为内存swap引起的,据说在Cassandra调优时有些直接把虚拟内存禁掉,以防出现类似的问题。
CMS收集器调节
重点当然是要调节CMS收集器,即要避免它在收集时停顿过长,更要避免产生promotion failed和concurrent mode failure的情况,因为这两种情况发生后都会引起CMS做Stop-The-World GC,其中第二中情况在做GC时还会做整理(Compact),因而会有更久的停顿,而我们这次是两种情况同时遇到了。要避免这两种情况的发生需要先理解为什么会发生这两种情况。
首先,我们来看看promotion failed发生的情况:
我们知道当对象到一定的年龄,或者在某次回收过程中一个Survivor无法装下所有存活的对象,或者一个对象的大小超过-XX:PretenureSizeThreshold设置的值,就会被promote到Old Gen中,而在promote过程中如果Old Gen无法装下所有的内存时就会产生promotion failed(这里说CMS是根据预估的值:Modern CMS collector estimates size of objects to be promoted using statistics from previous collections. If old space does not have enough free bytes to hold estimated promotion amount, concurrent mode failure will be raise.但是我的理解是这是ParNew做的GC,为什么由CMS来预估?所以应该是ParNew把需要promote的对象传递给CMS,然后CMS做预估决定是否有足够多的内存存放这些新promote的对象),此时就会先做一遍CMS GC,然后将新promote的对象存放到Old Gen中,由于这属于ParNew GC的范围,因而这次的CMS GC依然处于STW状态,因而这会花比较久的时间。如这次的ParNew GC,花费5.75s:
2015-07-20T08:23:00.028-0400: [GC [ParNew (promotion failed): 844305K->841244K(943744K), 0.2358120 secs][CMS: 6282619K->1891959K(9437184K), 5.5098940 secs] 7126660K->1891959K(10380928K), [CMS Perm : 67716K->66078K(112860K)], 5.7458460 secs] [Times: user=4.77 sys=0.02, real=5.75 secs]
我们当前的设置:-XX:CMSInitiatingOccupancyFraction=79 -XX:+UseCMSInitiatingOccupancyOnly,也就是只有到Old Gen占用79%后才会触发CMS GC,这里只占66.57%,因而还没能触发CMS GC,然而此时ParNew GC只回收了3061K的对象,还有841244K的对象存活,而一个Survivor只有104857.6K大小,此时需要promote大概736386.4K大小的数据到Old Gen中,此时Old Gen还有(9437184K-6282619K=3154565K)大小的内存,虽然远远大于被promote的对象,但是我们知道CMS采用标记-清除(Mark-Sweep)算法,而不主动整理(Compact),但是它在每次回收时会合并相邻的内存空间,然而跑久了难免会产生内存碎片,也就是说此时内存碎片已经很多了,无法容纳新promote的对象,要不就是新promote的对象大小太大,或者碎片太小,因而需要出发一次CMS GC来释放一些空间(此时发生的CMS是不是已经退化成Serial Old收集器算法了?即在收集的过程还是整理?),以保证能存放下所有新promote出的对象。这里GC日志中并没有显示出什么这次ParNew GC后,Young Gen还剩下多少内存,但是根据下一次ParNew GC的初始大小是838912K,小于之前存活下来的对象(841244K),因而我们可以确定这次GC的结果是把多余的对象promtoe到Old Gen中了。
所以根据分析,我们可以从两个方向调节参数:
1. 降低CMS GC的阈值。
2. 提前减少Old Gen中的碎片,即让CMS GC做整理。
对降低CMS GC阈值,我们可以调节参数:-XX:CMSInitiatingOccupancyFraction,默认大小68,我们可以将它设置成60。对提前减少Old Gen的碎片,这个比较麻烦,我们不想每次CMS GC都做整理(Compact),因而这会比较浪费,会增加GC总体的时间以及停顿的时间,而且也比较危险,因为万一某次整理时间太长会引起集群的不稳定(可以设置-XX:UseCMSCompactAtFullCollection来实现这个功能)。还有一种办法是设置每经历N次CMS后做一次整理,通过-XX:CMSFullGCsBeforeCompaction=<N>来设置,这个相对可以接受一些。当然如果能找到一个相对空闲的时间手动触发一次Full GC,并且确保让它能触发整理(这个目前还没有找到合适的方法,JConsole提供的GC按钮是一个可选项,但是不确定。我做了一下尝试:
2015-07-31T16:04:49.183+0800: [Full GC (System) [CMS: 3861443K->1072505K(9437184K), 2.9535440 secs] 4458634K->1072505K(10380928K), [CMS Perm : 68183K->67112K(112880K)], 2.9537000 secs] [Times: user=2.94 sys=0.00, real=2.95 secs]
看起来是减少了不少内存,按日志的格式,看起来像是Serial Old的算法?另外,使用这个功能要确保-XX:-DisableExplicitGC是关闭的,虽然这就是默认值)。
2015-07-22T08:20:04.712-0400: [GC [ParNew (promotion failed): 842147K->839927K(943744K), 0.2343990 secs][CMS: 7382737K->1921345K(9437184K), 378.2061130 secs] 8224802K->1921345K(10380928K), [CMS Perm : 67406K->66061K(112260K)], 378.4406520 secs] [Times: user=5.56 sys=1.80, real= 378.38 secs]
关于产生promotion failed,这里还有更详细的解释,如涉及到PLAB(Promotion Local Allocation Buffer)的介绍等,以及这里是对它的更正。值得注意的是,CMS在管理内存中使用空闲列表的方式,然而这种方式可能会存在某些问题,如这句话所说(具体就不展开了):
CMS memory manager is using separate free lists for different size of chunks. Using these free lists, it can effectively fill small holes in fragmented memory space with objects of exact size. This technique is known to be fairly effective (and widely used in C/C++ memory managers). But, surprisingly, it doesn't seems to work well for JVM in many real live situations.
然后,再来看看concurrent mode failure的情况:
依然是这篇文章,作者说concurrent mode failure是在Young GC发生前,它会预估Old Gen是否有足够的空间存放从Young Gen中promote上来的对象,CMS收集器根据之前统计来预估这些对象的大小,如果Old Gen没有足够的空间存放这个预估的值,就会发生concurrent mode failure。此时不一定会产生Full GC,有些时候JVM只是等待当前并行收集完成,但是用户程序此时还是会STW直到这次收集技术。
At beginning of each young GC, collector should ensure that there is enough free memory in old space to promote aged objects from young space. Modern CMS collector estimates size of objects to be promoted using statistics from previous collections. If old space does not have enough free bytes to hold estimated promotion amount, concurrent mode failure will be raise. Concurrent mode failure doesn't necessary lead to Full GC, in certain cases JVM will just wait for concurrent collection cycle to finish, but application will remain in STW pause until young collection will be finished.
然而根据我的观察,concurrent mode failure都是发生在CMS已经开始,但是在中间阶段,出现ParNew的promotion failed。
如这是发生在CMS-concurrent-abortable-preclean阶段:
这是ParNew发生时,CMS-concurrent-preclean结束不久,此时ParNew花费0.2429280s完成,想premote一些对象而失败,因而CMS在06:17:06.239完成步骤:CMS-concurrent-abortable-preclean,然而这个步骤结束后,依然无法容纳promote上来的对象,所以需要做一个Full GC,完成以后Old Gen内存从7747387K下降到1603792K,总内存从8556110K下降到1603792K,即将所有Young Gen中存活的对象放到了Old Gen中了。
这是发生在CMS-concurrent-sweep阶段:
这里基本和上面类似,ParNew在06:25:43.548结束,花费3.9544520s发现promotion failed,启动CMS GC,但在327.407s(从ParNew开始算起)完成CMS-concurrent-sweep后还是无法存放promote上来的对象,而发生concurrent mode failure,然后开始整理以容纳promote的对象,花费20s左右时间,最后做Perm GC,花费4s左右。这里CMS-concurrent-sweep之所以要花费327.407s的时间,应该还是和page faults相关吧?
这是发生在CMS-concurrent-mark阶段:
这里的问题类似,不赘述。
这是另一种情况下发生在CMS-concurrent-abortable-preclean阶段:2015-07-23T06:24:47.450-0400: [GC [1 CMS-initial-mark: 3938781K(5973632K)] 3998846K(6126976K), 0.0030340 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
这里ParNew发生时正在执行CMS-concurrent-abortable-preclean,而在结束时发生promotion failed,同时因为碎片问题发生concurrent mode failure,因而出现Full GC。
虽然分析了那么多,还有一个疑问没能解决,对这个问题的调节方法还是一样的:1. 降低CMS GC的阈值;2. 提前减少Old Gen中的碎片,即让CMS GC做整理。这里不再赘述。
最后,我们可以看看其他的调节方式:
1. 我们知道CMS收集器很多过程是并发进行的,因而我们可以控制并发线程的个数:-XX:ParallelCMSThreads=4,我们采用的值是4,和ParNew相同。CMS默认的回收线程数是(CPU个数+3)/4。
2. 在历史上还出现过增量式并发收集器(Incremental Concurrent Mark Sweep,i-CMS)。因为对少于4个CPU的时候,如果GC占用一般左右的CPU资源,会影响用户线程的吞吐量,因而增量式并发收集器的思想是在并发标记和并发清理时让用户线程和GC线程交替运行以减少GC占用太多的资源,特别是在只有一个CPU的情况下,即使在CMS并发阶段,还是会产生STW的现象,因而可以使用i-CMS来避免这种情况。然而i-CMS已经不再推荐使用,具体可以参考这里。大概的原因是现在已经很少只有一个CPU的硬件了,而且i-CMS在线程切换时会花费更多的资源和时间,它更加复杂,有更多的参数,难以调节,会产生更多的碎片。
3. 可以简单介绍一下CMS的整个过程(还可以参考这里,或者这里,虽然这里用的例子是1.4.2的版本,我用的是1.6.30,会有一些区别)。以一个正常的CMS为例:
1 CMS-initial-mark:初始标记标记阶段,STW,CMS起始点,标记所有Root对象直接可达的对象,7462216K是CMS触发时Old Gen的内存大小,7583692K是Old Gen+Young Gen的内存大小。
CMS-concurrent-mark:重新标记阶段,和用户线程同时处理,遍历初始标记出来的对象的所有直接或间接可达对象。CPU时间0.365s,总共0.365s(wall time)。这里times时间不是那么准确,因为起于该阶段的开始,但是中间可能包含其他的事情,如用户线程的执行影响等。
CMS-concurrent-preclean:preclean阶段,这个阶段依然和用户线程同时处理,它会继续查看在重新标记阶段新产生的对象:从Young Gen promote上来的对象,新分配的对象,或被修改的对象。这个阶段主要用于减少下一次重新标记(remark)的时间,因为重新标记会STW。CPU时间0.020s,总共0.020s(wall time)。
CMS-concurrent-abortable-preclean:可中断的preclean阶段,和用户线程同时处理,当preclean已经结束的时候就可以开始remark了,然而如果这样设计,由于CMS-concurrent-preclean和用户线程一起运行,当它结束的时候,Young Gen已经使用了很多了,但是这里面有很多是可以回收的,为了提升效率就可以先ParNew GC回收掉多余的对象,然后就会接着执行remark,就可以减少remark的时间;而如果Young Gen使用的很少,就可以立即启动remark过程;而如果介于之间,就可以先等待一会让eden占用一些空间后再启动remark,从而将ParNew GC的STW和remark的STW错分开。因而CMS的设计者就想出remark的执行时机还依赖于eden的使用情况(因为新对象总是先分配到eden空间),即通过两个变量来控制:CMSScheduleRemarkEdenSize和CMSScheduleRemarkEdenPenetration,它们的默认值分别是2M和50%,表示在CMS-concurrent-preclean结束后,如果eden只使用了少于2M,则直接启动remark;如果超过2M,但没有超过50%,则先启动CMS-concurrent-abortable-preclean,直到eden的使用率超过了50%,然后启动remark过程,这样就保证了remark和ParNew GC中间会有一定的时间间隔;如果已经超过了50%,就等待下次ParNew GC完成,然后再做remark?貌似只有这样才能解释的通这些日志,当remark阶段准备好后它可以随时被中断,它的中断不是错。如这种日志:
这个日志表示preclean结束后,eden的空间在2M和50%之间,因而启动abortable-preclean阶段,直到4.84s后才因为eden超过50%后中断CMS-concurrent-abortable-preclean,并开始remark,实际使用0.16s。
这个日志表示在CMS-concurrent-preclean结束后,eden使用空间已经超过50%,所以启动abortable-preclean,并在155ms后启动ParNew GC,在ParNew停止31ms后停止CMS-concurrent-abortable-preclean,而remark随后开始,经历30ms结束。
这个日志表示preclean结束后eden使用少于2M,因而跳过abortable-preclean,直接开始remark阶段。
1 CMS-remark:重新标记阶段,STW,这里YG occupancy表示该阶段开始时Young Gen的大小,因为这个阶段的时间长短部分依赖于Young Gen的大小;Rescan用于多线程扫描程序停止时存活的对象,花费0.1319540s;weak refs processing用于处理weak reference的情况,花费0.0001s,如果这个阶段花费太长时间,可以打开开关:-XX:+ParallelRefProcEnabled来调节;然后开始重新标记,即重新从Root对象遍历标记存活对象。
CMS-concurrent-sweep:并发清理,和其他线程同时执行,做真正的清理工作,如把可以回收的内存放回空闲列表,合并相邻内存空间等。
CMS-concurrent-reset:重置CMS的状态用于下一次执行。
减少类的创建
减少类的创建就需要改代码,还要分析项目的特点,但是总体上可以有三个方向:
- 实实在在的减少不必要的类创建,如是否可以字段来缓存一个对象而不是每次在调用时都新建一个类,设置可以考虑用一些静态方法而不是实例类等。
- 将某些常用的对象缓存起来,每次都从缓存中拿对象而不是新建一个对象,甚至有些时候我们已经新创建了一个对象也要替换成缓存中的对象,这样新创建的对象就可以直接被回收,而在缓存中的对象很多时候会留在Old Gen中而不需要被回收。很多开源的项目中就有使用这个技术,如String的intern方式就是类似的设计,记得有些面试提会问new String("abc")创建了几个String对象,以及"abc"==String.valueOf("abc")的问题等,但是有多少人直到这种现象的设计意图和内部原理呢?
- 自己管理对象的创建,即自己维护一个或多个Buffer,然后自己管理和使用这些Buffer,如Jetty中的BufferCache的实现。
后记
这篇博客写了一个多星期了,终于快收尾了。对于GC,所实话自己以前关注的并不多,一直觉得自己了解到的东西已经够用了,直到这次一个同事把我们项目中GemFire节点调出和GC的停顿时间联系起来,然后我突然感觉很多问题豁然开朗了。因而这一周潜心读了很多相关文章来完成这篇博客。其实还有很多可以说的,或者自己不了解的,这些只能留待以后慢慢补充,但是经历这个事件后,发现自己对GC的了解和一些设计思想提升了一个档次,记之。
参考:
promotion failed/concurrent mode failure explain: http://blog.ragozin.info/2011/10/java-cg-hotspots-cms-and-heap.html
JVM Options: http://www.oracle.com/technetwork/articles/java/vmoptions-jsp-140102.html
CMS GC Log(JDK 1.8.0-ea): https://blogs.oracle.com/jonthecollector/entry/the_unspoken_cms_and_printgcdetails
GC Log Parser Code:https://java.net/projects/gchisto
CMS介绍:https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/cms.html
HotSpot Glossary: http://openjdk.java.net/groups/hotspot/docs/HotSpotGlossary.html
其他相关或不相关的:
https://blogs.oracle.com/jonthecollector/entry/when_the_sum_of_the