Java教程:9种常见的CMS GC问题详解(八)

开课吧开课吧锤锤2021-03-23 10:46

    Java编程语言是一种简单、面向对象、分布式、解释型、健壮安全、与系统无关、可移植、高性能、多线程和动态的语言。如今Java已经广泛应用于各个领域的编程开发。

Java

    4.4场景四:过早晋升

    4.4.1现象

    这种场景主要发生在分代的收集器上面,专业的术语称为“PrematurePromotion”。90%的对象朝生夕死,只有在Young区经历过几次GC的洗礼后才会晋升到Old区,每经历一次GC对象的GCAge就会增长1,最大通过-XX:MaxTenuringThreshold来控制。

    过早晋升一般不会直接影响GC,总会伴随着浮动垃圾、大对象担保失败等问题,但这些问题不是立刻发生的,我们可以观察以下几种现象来判断是否发生了过早晋升。

    分配速率接近于晋升速率,对象晋升年龄较小。

    GC日志中出现“Desiredsurvivorsize107347968bytes,newthreshold1(max6)”等信息,说明此时经历过一次GC就会放到Old区。

    FullGC比较频繁,且经历过一次GC之后Old区的变化比例非常大。

    比如说Old区触发的回收阈值是80%,经历过一次GC之后下降到了10%,这就说明Old区的70%的对象存活时间其实很短,如下图所示,Old区大小每次GC后从2.1G回收到300M,也就是说回收掉了1.8G的垃圾,只有300M的活跃对象。整个Heap目前是4G,活跃对象只占了不到十分之一。

Java

    过早晋升的危害:

    YoungGC频繁,总的吞吐量下降。

    FullGC频繁,可能会有较大停顿。

    4.4.2原因

    主要的原因有以下两点:

    Young/Eden区过小:过小的直接后果就是Eden被装满的时间变短,本应该回收的对象参与了GC并晋升,YoungGC采用的是复制算法,由基础篇我们知道copying耗时远大于mark,也就是YoungGC耗时本质上就是copy的时间(CMS扫描CardTable或G1扫描RememberSet出问题的情况另说),没来及回收的对象增大了回收的代价,所以YoungGC时间增加,同时又无法快速释放空间,YoungGC次数也跟着增加。

    分配速率过大:可以观察出问题前后Mutator的分配速率,如果有明显波动可以尝试观察网卡流量、存储类中间件慢查询日志等信息,看是否有大量数据被加载到内存中。

    同时无法GC掉对象还会带来另外一个问题,引发动态年龄计算:JVM通过-XX:MaxTenuringThreshold参数来控制晋升年龄,每经过一次GC,年龄就会加一,达到最大年龄就可以进入Old区,最大值为15(因为JVM中使用4个比特来表示对象的年龄)。设定固定的MaxTenuringThreshold值作为晋升条件:

    MaxTenuringThreshold如果设置得过大,原本应该晋升的对象一直停留在Survivor区,直到Survivor区溢出,一旦溢出发生,Eden+Survivor中对象将不再依据年龄全部提升到Old区,这样对象老化的机制就失效了。

    MaxTenuringThreshold如果设置得过小,过早晋升即对象不能在Young区充分被回收,大量短期对象被晋升到Old区,Old区空间迅速增长,引起频繁的MajorGC,分代回收失去了意义,严重影响GC性能。

    相同应用在不同时间的表现不同,特殊任务的执行或者流量成分的变化,都会导致对象的生命周期分布发生波动,那么固定的阈值设定,因为无法动态适应变化,会造成和上面问题,所以Hotspot会使用动态计算的方式来调整晋升的阈值。

    具体动态计算可以看一下Hotspot源码,具体在/src/hotspot/share/gc/shared/ageTable.cpp的compute_tenuring_threshold方法中:

uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
  //TargetSurvivorRatio默认50,意思是:在回收之后希望survivor区的占用率达到这个比例
  size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);
  size_t total = 0;
  uint age = 1;
  assert(sizes[0] == 0, "no objects with age zero should be recorded");
  while (age < table_size) {//table_size=16
    total += sizes[age];
    //如果加上这个年龄的所有对象的大小之后,占用量>期望的大小,就设置age为新的晋升阈值
    if (total > desired_survivor_size) break;
    age++;
  }

  uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
  if (PrintTenuringDistribution || UsePerfData) {

    //打印期望的survivor的大小以及新计算出来的阈值,和设置的最大阈值
    if (PrintTenuringDistribution) {
      gclog_or_tty->cr();
      gclog_or_tty->print_cr("Desired survivor size " SIZE_FORMAT " bytes, new threshold %u (max %u)",
        desired_survivor_size*oopSize, result, (int) MaxTenuringThreshold);
    }

    total = 0;
    age = 1;
    while (age < table_size) {
      total += sizes[age];
      if (sizes[age] > 0) {
        if (PrintTenuringDistribution) {
          gclog_or_tty->print_cr("- age %3u: " SIZE_FORMAT_W(10) " bytes, " SIZE_FORMAT_W(10) " total",
                                        age,    sizes[age]*oopSize,          total*oopSize);
        }
      }
      if (UsePerfData) {
        _perf_sizes[age]->set_value(sizes[age]*oopSize);
      }
      age++;
    }
    if (UsePerfData) {
      SharedHeap* sh = SharedHeap::heap();
      CollectorPolicy* policy = sh->collector_policy();
      GCPolicyCounters* gc_counters = policy->counters();
      gc_counters->tenuring_threshold()->set_value(result);
      gc_counters->desired_survivor_size()->set_value(
        desired_survivor_size*oopSize);
    }
  }

  return result;
}

    可以看到Hotspot遍历所有对象时,从所有年龄为0的对象占用的空间开始累加,如果加上年龄等于n的所有对象的空间之后,使用Survivor区的条件值(TargetSurvivorRatio/100,TargetSurvivorRatio默认值为50)进行判断,若大于这个值则结束循环,将n和MaxTenuringThreshold比较,若n小,则阈值为n,若n大,则只能去设置最大阈值为MaxTenuringThreshold。动态年龄触发后导致更多的对象进入了Old区,造成资源浪费。

    4.4.3策略

    知道问题原因后我们就有解决的方向,如果是Young/Eden区过小,我们可以在总的Heap内存不变的情况下适当增大Young区,具体怎么增加?一般情况下Old的大小应当为活跃对象的2~3倍左右,考虑到浮动垃圾问题最好在3倍左右,剩下的都可以分给Young区。

    拿笔者的一次典型过早晋升优化来看,原配置为Young1.2G+Old2.8G,通过观察CMSGC的情况找到存活对象大概为300~400M,于是调整Old1.5G左右,剩下2.5G分给Young区。仅仅调了一个Young区大小参数(-Xmn),整个JVM一分钟YoungGC从26次降低到了11次,单次时间也没有增加,总的GC时间从1100ms降低到了500ms,CMSGC次数也从40分钟左右一次降低到了7小时30分钟一次。

Java

Java

    如果是分配速率过大:

    偶发较大:通过内存分析工具找到问题代码,从业务逻辑上做一些优化。

    一直较大:当前的Collector已经不满足Mutator的期望了,这种情况要么扩容Mutator的VM,要么调整GC收集器类型或加大空间。

    4.4.4小结

    过早晋升问题一般不会特别明显,但日积月累之后可能会爆发一波收集器退化之类的问题,所以我们还是要提前避免掉的,可以看看自己系统里面是否有这些现象,如果比较匹配的话,可以尝试优化一下。一行代码优化的ROI还是很高的。

    如果在观察Old区前后比例变化的过程中,发现可以回收的比例非常小,如从80%只回收到了60%,说明我们大部分对象都是存活的,Old区的空间可以适当调大些。

4.4.5加餐

    关于在调整Young与Old的比例时,如何选取具体的NewRatio值,这里将问题抽象成为一个蓄水池模型,找到以下关键衡量指标,大家可以根据自己场景进行推算。

Java

Java

    NewRatio的值r与va、vp、vyc、voc、rs等值存在一定函数相关性(rs越小r越大、r越小vp越小,…,之前尝试使用NN来辅助建模,但目前还没有完全算出具体的公式,有想法的同学可以在评论区给出你的答案)。

    总停顿时间T为YoungGC总时间Tyc和OldGC总时间Toc之和,其中Tyc与vyc和vp相关,Toc与voc相关。

    忽略掉GC时间后,两次YoungGC的时间间隔要大于TP9999时间,这样尽量让对象在Eden区就被回收,可以减少很多停顿。

    以上内容由开课吧老师、新宇、湘铭、祥璞提供,更多Java教程尽在开课吧广场Java教程频道。

有用1
分享