赞
踩
目录
并发标记——对老年代所有对象进行GC Roots追踪(最耗时)
CMS 的全称是 Mostly Concurrent Mark and Sweep Garbage Collector(主要并发标记清除垃圾收集器),它在年轻代使用复制算法,而对老年代使用标记-清除算法。
CMS 的设计目标,是避免在老年代 GC 时出现长时间的卡顿(但它并不是一个老年代回收器)。如果不希望有长时间的停顿,同时你的 CPU 资源也比较丰富,使用 CMS 是比较合适的。
CMS 使用的是 Sweep 而不是 Compact,所以它的主要问题是碎片化。随着 JVM 的长时间运行,碎片化会越来越严重,只有通过 Full GC 才能完成整理。
为啥不使用Compact呢?
在于其目标本身就是为了减少STW,这样可以采用标记清除算法保证用户线程与GC线程同时运行消除STW,如果采用标记整理算法,有可能会导致移动内存地址,会发生的stw问题。
为了避免长时间Stop the World,CMS采用了4个阶段来垃圾回收,分别是初始标记、并发标记、重新标记和并发清理。其中初始标记和重新标记,耗时很短,虽然会导致Stop the World,但是影响不大,然后并发标记和并发清理,两个阶段耗时最长,但是是可以跟系统的工作线程并发运行的,所以对系统没太大影响。具体图示如下。
初始标记阶段,只标记直接关联 GC root 的对象,不用向下追溯。因为最耗时的就在 tracing 阶段,这样就极大地缩短了初始标记时间。这个过程是 STW 的,但由于只是标记第一层,所以速度是很快的。
下面这段代码,仅仅会通过replicaManager这个类的静态变量代表的GC Roots,去标记出来他直接引用的ReplicaManager对象,不会去管ReplicaFetcher这种对象,因为ReplicaFetcher对象是被ReplicaManager类的replicaFetcher实例变量引用的。
- public class Kafka {
- private static ReplicaManager replicaManager = new ReplicaManager();
- }
- public class ReplicaManager {
- private ReplicaFetcher replicaFetcher = new ReplicaFetcher();
- }
对应图示如下:
方法的局部变量和类的静态变量是GC Roots,但是类的实例变量不是GC Roots。
在初始标记的基础上,进行并发标记。这一步骤主要是 tracinng 的过程,用于标记所有可达的对象。这个过程会持续比较长的时间,但却可以和用户线程并行。在这个阶段的执行过程中,可能会产生很多变化:
在这个阶段受到影响的老年代对象所对应的卡页,会被标记为 dirty,用于后续重新标记阶段的扫描。注意,这个过程紧着往往存在并发预清理,该阶段也是不需要 STW 的,目的是为了让重新标记阶段的 STW 尽可能短。这个时候,老年代中被标记为 dirty 的卡页中的对象,就会被重新标记,然后清除掉 dirty 的状态。由于这个阶段也是可以并发的,在执行过程中引用关系依然会发生一些变化。我们可以假定这个清理动作是第一次清理。所以重新标记阶段,有可能还会有处于 dirty 状态的卡页。
这个阶段会让系统线程可以随意创建各种新对象,继续运行。在运行期间可能会创建新的存活对象,也可能会让部分存活对象失去引用,变成垃圾对象。在这个过程中,垃圾回收线程,会尽可能的对已有的对象进行GC Roots追踪。GC Roots追踪,意思就是对类似ReplicaFetcher之类的全部老年代里的对象,他会去看他被谁引用了,认定为是被GC Roots间接引用后,就不需要回收它。因为老年代里存活对象是比较多的,这个过程会追踪大量的对象,所以耗时较高。
重新标记阶段则是为了修正并发标记期间因用户程序继续运作导致标记产生变动的那一部分对象的标记记录,这个阶段暂停时间比初始标记阶段稍长一点,但远比并发标记时间短。
CMS用增量更新来做并发标记,也就是说并发标记过程中,如果某个已经标记为存活的对象增加了对非存活对象的引用,那么将其标记为灰色,然后在重新标记阶段将这一部分对象重新扫描。
这个阶段,要再次进入Stop the World
阶段,重新标记下在第二阶段里新创建的一些对象,还有一些已有对象可能失去引用变成垃圾的情况。重新标记的阶段只是对变动过的少数对象进行标记,是速度很快的。
此阶段用户线程被重新激活,目标是删掉不可达的对象,并回收它们的空间。
由于 CMS 并发清理阶段用户线程还在运行中,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次 GC 中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为“浮动垃圾”。
这个阶段就是让系统程序随意运行,然后清理掉之前标记为垃圾的对象即可,也是很耗时的。
优势:低延迟,尤其对于大堆来说。大部分垃圾回收过程并发执行。
劣势:
CMS 对老年代回收的时候,并没有内存的整理阶段。这就造成程序在长时间运行之后,碎片太多。如果你申请一个稍大的对象,就会引起分配失败。
CMS 提供了两个参数来解决这个问题:
所以,预留空间加上内存的碎片,使用 CMS 垃圾回收器的老年代,留给我们的空间就不是太多,这也是 CMS 的一个弱点。
JVM内存主要的三大分区:新生代、老年代、永久代
新生代内又分三个区:一个Eden区,两个Survivor区(一般而言),大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到两个Survivor区(中的一个)。当这个Survivor区满时,此区的存活且不满足“晋升”条件的对象将被复制到另外一个Survivor区。对象每经历一次Minor GC,年龄加1,达到“晋升年龄阈值”后,被放到老年代,这个过程也称为“晋升”。显然,“晋升年龄阈值”的大小直接影响着对象在新生代中的停留时间,在Serial和ParNew GC两种回收器中,“晋升年龄阈值”通过参数MaxTenuringThreshold设定,默认值为15。
各分区的大小对GC的性能影响很大。如何将各分区调整到合适的大小,分析活跃数据的大小是很好的切入点。
活跃数据的大小是指,应用程序稳定运行时长期存活对象在堆中占用的空间大小,也就是Full GC后堆中老年代占用空间的大小。可以通过GC日志中Full GC之后老年代数据大小得出,比较准确的方法是在程序稳定后,多次获取GC数据,通过取平均值的方式计算活跃数据的大小。
例如,根据GC日志获得老年代的活跃数据大小为300M,那么各分区大小可以设为:
- 总堆:1200MB = 300MB × 4
- 新生代:450MB = 300MB × 1.5
- 老年代: 750MB = 1200MB - 450MB
明确应用程序的系统需求是性能优化的基础,系统的需求是指应用程序运行时某方面的要求,譬如:
高可用,可用性达到几个9。
低延迟,请求必须多少毫秒内完成响应。
高吞吐,每秒完成多少次事务。
明确系统需求之所以重要,是因为上述性能指标间可能冲突。比如通常情况下,缩小延迟的代价是降低吞吐量或者消耗更多的内存或者两者同时发生。
假设团队主要关注高可用和低延迟两项指标,所以接下来分析,如何量化GC时间和频率对于响应时间和可用性的影响。通过这个量化指标,可以计算出当前GC情况对服务的影响,也能评估出GC优化后对响应时间的收益,这两点对于低延迟服务很重要。
举例:假设单位时间T内发生一次持续25ms的GC,接口平均响应时间为50ms,且请求均匀到达,根据下图所示:
那么有(50ms+25ms)/T比例的请求会受GC影响,其中GC前的50ms内到达的请求都会增加25ms,GC期间的25ms内到达的请求,会增加0-25ms不等,如果时间T内发生N次GC,受GC影响请求占比=(接口响应时间+GC时间)×N/T 。可见无论降低单次GC时间还是降低GC次数N都可以有效减少GC对响应时间的影响。
确定目标
服务情况:Minor GC每分钟100次 ,Major GC每4分钟一次,单次Minor GC耗时25ms,单次Major GC耗时200ms,接口响应时间50ms。
由于这个服务要求低延时高可用,结合上文中提到的GC对服务响应时间的影响,计算可知由于Minor GC的发生,12.5%的请求响应时间会增加,其中8.3%的请求响应时间会增加25ms,可见当前GC情况对响应时间影响较大。
(50ms+25ms)× 100次/60000ms = 12.5%,50ms × 100次/60000ms = 8.3% 。
优化目标:降低TP99、TP90时间。
优化
首先优化Minor GC频繁问题。通常情况下,由于新生代空间较小,Eden区很快被填满,就会导致频繁Minor GC,因此可以通过增大新生代空间来降低Minor GC的频率。例如在相同的内存分配率的前提下,新生代中的Eden区增加一倍,Minor GC的次数就会减少一半。
这时很多人有这样的疑问,扩容Eden区虽然可以减少Minor GC的次数,但会增加单次Minor GC时间么?根据上面公式,如果单次Minor GC时间也增加,很难保证最后的优化效果。我们结合下面情况来分析,单次Minor GC时间主要受哪些因素影响?是否和新生代大小存在线性关系? 首先,单次Minor GC时间由以下两部分组成:T1(扫描新生代)和 T2(复制存活对象到Survivor区)如下图。(注:这里为了简化问题,我们认为T1只扫描新生代判断对象是否存活的时间,其实该阶段还需要扫描部分老年代,后面案例中有详细描述。)
扩容前:新生代容量为R ,假设对象A的存活时间为750ms,Minor GC间隔500ms,那么本次Minor GC时间= T1(扫描新生代R)+T2(复制对象A到S)。
扩容后:新生代容量为2R ,对象A的生命周期为750ms,那么Minor GC间隔增加为1000ms,此时Minor GC对象A已不再存活,不需要把它复制到Survivor区,那么本次GC时间 = 2 × T1(扫描新生代R),没有T2复制时间。
可见,扩容后,Minor GC时增加了T1(扫描时间),但省去T2(复制对象)的时间,更重要的是对于虚拟机来说,复制对象的成本要远高于扫描成本,所以,单次Minor GC时间更多取决于GC后存活对象的数量,而非Eden区的大小。因此如果堆中短期对象很多,那么扩容新生代,单次Minor GC时间不会显著增加。下面需要确认下服务中对象的生命周期分布情况:
通过上图GC日志中两处红色框标记内容可知: 1. new threshold = 2(动态年龄判断,对象的晋升年龄阈值为2),对象仅经历2次Minor GC后就晋升到老年代,这样老年代会迅速被填满,直接导致了频繁的Major GC。 2. Major GC后老年代使用空间为300M+,意味着此时绝大多数(86% = 2G/2.3G)的对象已经不再存活,也就是说生命周期长的对象占比很小。
由此可见,服务中存在大量短期临时对象,扩容新生代空间后,Minor GC频率降低,对象在新生代得到充分回收,只有生命周期长的对象才进入老年代。这样老年代增速变慢,Major GC频率自然也会降低。
优化结果
通过扩容新生代为为原来的三倍,单次Minor GC时间增加小于5ms,频率下降了60%,服务响应时间TP90,TP99都下降了10ms+,服务可用性得到提升。
调整前:
调整后:
小结
如何选择各分区大小应该依赖应用程序中对象生命周期的分布情况:如果应用存在大量的短期对象,应该选择较大的年轻代;如果存在相对较多的持久对象,老年代应该适当增大。
确定目标
GC日志显示,高峰期CMS在重标记(Remark)阶段耗时1.39s。Remark阶段是Stop-The-World(以下简称为STW)的,即在执行垃圾回收时,Java应用程序中除了垃圾回收器线程之外其他所有线程都被挂起,意味着在此期间,用户正常工作的线程全部被暂停下来,这是低延时服务不能接受的。本次优化目标是降低Remark时间。
优化
解决问题前,先回顾一下CMS的四个主要阶段,以及各个阶段的工作内容。下图展示了CMS各个阶段可以标记的对象,用不同颜色区分。 1. Init-mark初始标记(STW) ,该阶段进行可达性分析,标记GC ROOT能直接关联到的对象,所以很快。 2. Concurrent-mark并发标记,由前阶段标记过的绿色对象出发,所有可到达的对象都在本阶段中标记。 3. Remark重标记(STW) ,暂停所有用户线程,重新扫描堆中的对象,进行可达性分析,标记活着的对象。因为并发标记阶段是和用户线程并发执行的过程,所以该过程中可能有用户线程修改某些活跃对象的字段,指向了一个未标记过的对象,如下图中红色对象在并发标记开始时不可达,但是并行期间引用发生变化,变为对象可达,这个阶段需要重新标记出此类对象,防止在下一阶段被清理掉,这个过程也是需要STW的。特别需要注意一点,这个阶段是以新生代中对象为根来判断对象是否存活的。 4. 并发清理,进行并发的垃圾清理。
可见,Remark阶段主要是通过扫描堆来判断对象是否存活。那么准确判断对象是否存活,需要扫描哪些对象?CMS对老年代做回收,Remark阶段仅扫描老年代是否可行?结论是不可行,原因如下:
如果仅扫描老年代中对象,即以老年代中对象为根,判断对象是否存在引用,上图中,对象A因为引用存在新生代中,它在Remark阶段就不会被修正标记为可达,GC时会被错误回收。 新生代对象持有老年代中对象的引用,这种情况称为“跨代引用”。因它的存在,Remark阶段必须扫描整个堆来判断对象是否存活,包括图中灰色的不可达对象。
灰色对象已经不可达,但仍然需要扫描的原因:新生代GC和老年代的GC是各自分开独立进行的,只有Minor GC时才会使用根搜索算法,标记新生代对象是否可达,也就是说虽然一些对象已经不可达,但在Minor GC发生前不会被标记为不可达,CMS也无法辨认哪些对象存活,只能全堆扫描(新生代+老年代)。由此可见堆中对象的数目影响了Remark阶段耗时。 分析GC日志可以得出同样的规律,Remark耗时>500ms时,新生代使用率都在75%以上。这样降低Remark阶段耗时问题转换成如何减少新生代对象数量。
新生代中对象的特点是“朝生夕灭”,这样如果Remark前执行一次Minor GC,大部分对象就会被回收。CMS就采用了这样的方式,在Remark前增加了一个可中断的并发预清理(CMS-concurrent-abortable-preclean),该阶段主要工作仍然是并发标记对象是否存活,只是这个过程可被中断。此阶段在Eden区使用超过2M时启动,当然2M是默认的阈值,可以通过参数修改。如果此阶段执行时等到了Minor GC,那么上述灰色对象将被回收,Reamark阶段需要扫描的对象就少了。
除此之外CMS为了避免这个阶段没有等到Minor GC而陷入无限等待,提供了参数CMSMaxAbortablePrecleanTime ,默认为5s,含义是如果可中断的预清理执行超过5s,不管发没发生Minor GC,都会中止此阶段,进入Remark。 根据GC日志红色标记2处显示,可中断的并发预清理执行了5.35s,超过了设置的5s被中断,期间没有等到Minor GC ,所以Remark时新生代中仍然有很多对象。
对于这种情况,CMS提供CMSScavengeBeforeRemark参数,用来保证Remark前强制进行一次Minor GC。
优化结果
经过增加CMSScavengeBeforeRemark参数,单次执行时间>200ms的GC停顿消失,从监控上观察,GCtime和业务波动保持一致,不再有明显的毛刺。
小结
通过案例分析了解到,由于跨代引用的存在,CMS在Remark阶段必须扫描整个堆,同时为了避免扫描时新生代有很多对象,增加了可中断的预清理阶段用来等待Minor GC的发生。只是该阶段有时间限制,如果超时等不到Minor GC,Remark时新生代仍然有很多对象,我们的调优策略是,通过参数强制Remark前进行一次Minor GC,从而降低Remark阶段的时间。
确定目标
GC日志如下图(在GC日志中,Full GC是用来说明这次垃圾回收的停顿类型,代表STW类型的GC,并不特指老年代GC),根据GC日志可知本次Full GC耗时1.23s。这个在线服务同样要求低时延高可用。本次优化目标是降低单次STW回收停顿时间,提高可用性。
优化
首先,什么时候可能会触发STW的Full GC呢? 1. Perm空间不足; 2. CMS GC时出现promotion failed和concurrent mode failure(concurrent mode failure发生的原因一般是CMS正在进行,但是由于老年代空间不足,需要尽快回收老年代里面的不再被使用的对象,这时停止所有的线程,同时终止CMS,直接进行Serial Old GC); 3. 统计得到的Young GC晋升到老年代的平均大小大于老年代的剩余空间; 4. 主动触发Full GC(执行jmap -histo:live [pid])来避免碎片问题。
然后,我们来逐一分析一下: - 排除原因2:如果是原因2中两种情况,日志中会有特殊标识,目前没有。 - 排除原因3:根据GC日志,当时老年代使用量仅为20%,也不存在大于2G的大对象产生。 - 排除原因4:因为当时没有相关命令执行。 - 锁定原因1:根据日志发现Full GC后,Perm区变大了,推断是由于永久代空间不足容量扩展导致的。
找到原因后解决方法有两种: 1. 通过把-XX:PermSize参数和-XX:MaxPermSize设置成一样,强制虚拟机在启动的时候就把永久代的容量固定下来,避免运行时自动扩容。 2. CMS默认情况下不会回收Perm区,通过参数CMSPermGenSweepingEnabled、CMSClassUnloadingEnabled ,可以让CMS在Perm区容量不足时对其回收。
由于该服务没有生成大量动态类,回收Perm区收益不大,所以我们采用方案1,启动时将Perm区大小固定,避免进行动态扩容。
优化结果
调整参数后,服务不再有Perm区扩容导致的STW GC发生。
小结
对于性能要求很高的服务,建议将MaxPermSize和MinPermSize设置成一致(JDK8开始,Perm区完全消失,转而使用元空间。而元空间是直接存在内存中,不在JVM中),Xms和Xmx也设置为相同,这样可以减少内存自动扩容和收缩带来的性能损失。虚拟机启动的时候就会把参数中所设定的内存全部化为私有,即使扩容前有一部分内存不会被用户代码用到,这部分内存在虚拟机中被标识为虚拟内存,也不会交给其他进程使用。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。