JVM内存对象分配率和提升率

对JVM关注最多的就是其内存管理、垃圾回收,本文从java内存对象在JVM中的分配率和提升率来分析对程序性能的影响。主要信息来自网络。


高分配速率(High Allocation Rate)

分配速率(Allocation rate)表示单位时间内分配的内存量。通常使用 MB/sec作为单位, 也可以使用 PB/year 等。

分配速率过高就会严重影响程序的性能。在JVM中会导致巨大的GC开销。

如何测量分配速率?

指定JVM参数: -XX:+PrintGCDetails -XX:+PrintGCTimeStamps , 通过GC日志来计算分配速率. GC日志如下所示:

0.291: [GC (Allocation Failure) 
        [PSYoungGen: 33280K->5088K(38400K)] 
        33280K->24360K(125952K), 0.0365286 secs] 
    [Times: user=0.11 sys=0.02, real=0.04 secs] 
0.446: [GC (Allocation Failure) 
        [PSYoungGen: 38368K->5120K(71680K)] 
        57640K->46240K(159232K), 0.0456796 secs] 
    [Times: user=0.15 sys=0.02, real=0.04 secs] 
0.829: [GC (Allocation Failure) 
        [PSYoungGen: 71680K->5120K(71680K)] 
        112800K->81912K(159232K), 0.0861795 secs] 
    [Times: user=0.23 sys=0.03, real=0.09 secs]

计算 上一次垃圾收集之后,与下一次GC开始之前的年轻代使用量, 两者的差值除以时间,就是分配速率。 通过上面的日志, 可以计算出以下信息:

  • JVM启动之后 291ms, 共创建了 33,280 KB 的对象。 第一次 Minor GC(小型GC) 完成后, 年轻代中还有 5,088 KB 的对象存活。
  • 在启动之后 446 ms, 年轻代的使用量增加到 38,368 KB, 触发第二次GC, 完成后年轻代的使用量减少到 5,120 KB
  • 在启动之后 829 ms, 年轻代的使用量为 71,680 KB, GC后变为 5,120 KB

可以通过年轻代的使用量来计算分配速率, 如下表所示:

EventTimeYoung beforeYoung afterAllocated duringAllocation rate
1st GC291ms33,280KB5,088KB33,280KB114MB/sec
2nd GC446ms38,368KB5,120KB33,280KB215MB/sec
3rd GC829ms71,680KB5,120KB66,560KB174MB/sec
Total829msN/AN/A133,120KB161MB/sec

通过这些信息可以知道, 在测量期间, 该程序的内存分配速率为 161 MB/sec

分配速率的意义

分配速率的变化,会增加或降低GC暂停的频率, 从而影响吞吐量。 但只有年轻代的 minor GC 受分配速率的影响, 老年代GC的频率和持续时间不受 分配速率(allocation rate)的直接影响, 而是受到 提升速率(promotion rate)的影响, 请参见下文。

现在我们只关心 Minor GC 暂停, 查看年轻代的3个内存池。因为对象在 Eden区分配, 所以我们一起来看 Eden 区的大小和分配速率的关系. 看看增加 Eden 区的容量, 能不能减少 Minor GC 暂停次数, 从而使程序能够维持更高的分配速率。

经过我们的实验, 通过参数 -XX:NewSize-XX:MaxNewSize 以及 -XX:SurvivorRatio 设置不同的 Eden 空间, 运行同一程序时, 可以发现:

  • Eden 空间为 100 MB 时, 分配速率低于 100 MB/秒
  • 将 Eden 区增大为 1 GB, 分配速率也随之增长,大约等于 200 MB/秒

为什么会这样? —— 因为减少GC暂停,就等价于减少了任务线程的停顿,就可以做更多工作, 也就创建了更多对象, 所以对同一应用来说, 分配速率越高越好。

在得出 “Eden区越大越好” 这个结论前, 我们注意到, 分配速率可能会,也可能不会影响程序的实际吞吐量。 吞吐量和分配速率有一定关系, 因为分配速率会影响 minor GC 暂停, 但对于总体吞吐量的影响, 还要考虑 Major GC(大型GC)暂停, 而且吞吐量的单位不是 MB/秒, 而是系统所处理的业务量。

示例

参考 Demo程序。假设系统连接了一个外部的数字传感器。应用通过专有线程, 不断地获取传感器的值,(此处使用随机数模拟), 其他线程会调用 processSensorValue() 方法, 传入传感器的值来执行某些操作, :

public class BoxingFailure {
  private static volatile Double sensorValue;

  private static void readSensor() {
    while(true) sensorValue = Math.random();
  }

  private static void processSensorValue(Double value) {
    if(value != null) {
      //...
    }
  }
}

如同类名所示, 这个Demo是模拟 boxing 的。为了 null 值判断, 使用的是包装类型 Double。 程序基于传感器的最新值进行计算, 但从传感器取值是一个重量级操作, 所以采用了异步方式: 一个线程不断获取新值, 计算线程则直接使用暂存的最新值, 从而避免同步等待。

Demo 程序在运行的过程中, 由于分配速率太大而受到GC的影响。下一节将确认问题, 并给出解决办法。

高分配速率对JVM的影响

首先,我们应该检查程序的吞吐量是否降低。如果创建了过多的临时对象, minor GC的次数就会增加。如果并发较大, 则GC可能会严重影响吞吐量。

遇到这种情况时, GC日志将会像下面这样,当然这是上面的示例程序 产生的GC日志。 JVM启动参数为 -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xmx32m:

2.808: [GC (Allocation Failure) 
        [PSYoungGen: 9760K->32K(10240K)], 0.0003076 secs]
2.819: [GC (Allocation Failure) 
        [PSYoungGen: 9760K->32K(10240K)], 0.0003079 secs]
2.830: [GC (Allocation Failure) 
        [PSYoungGen: 9760K->32K(10240K)], 0.0002968 secs]
2.842: [GC (Allocation Failure) 
        [PSYoungGen: 9760K->32K(10240K)], 0.0003374 secs]
2.853: [GC (Allocation Failure) 
        [PSYoungGen: 9760K->32K(10240K)], 0.0004672 secs]
2.864: [GC (Allocation Failure) 
        [PSYoungGen: 9760K->32K(10240K)], 0.0003371 secs]
2.875: [GC (Allocation Failure) 
        [PSYoungGen: 9760K->32K(10240K)], 0.0003214 secs]
2.886: [GC (Allocation Failure) 
        [PSYoungGen: 9760K->32K(10240K)], 0.0003374 secs]
2.896: [GC (Allocation Failure) 
        [PSYoungGen: 9760K->32K(10240K)], 0.0003588 secs]

很显然 minor GC 的频率太高了。这说明创建了大量的对象。另外, 年轻代在 GC 之后的使用量又很低, 也没有 full GC 发生。 种种迹象表明, GC对吞吐量造成了严重的影响。

解决方案

在某些情况下,只要增加年轻代的大小, 即可降低分配速率过高所造成的影响。增加年轻代空间并不会降低分配速率, 但是会减少GC的频率。如果每次GC后只有少量对象存活, minor GC 的暂停时间就不会明显增加。

运行 示例程序 时, 增加堆内存大小,(同时也就增大了年轻代的大小), 使用的JVM参数为 -Xmx64m:

2.808: [GC (Allocation Failure) 
        [PSYoungGen: 20512K->32K(20992K)], 0.0003748 secs]
2.831: [GC (Allocation Failure) 
        [PSYoungGen: 20512K->32K(20992K)], 0.0004538 secs]
2.855: [GC (Allocation Failure) 
        [PSYoungGen: 20512K->32K(20992K)], 0.0003355 secs]
2.879: [GC (Allocation Failure) 
        [PSYoungGen: 20512K->32K(20992K)], 0.0005592 secs]

但有时候增加堆内存的大小,并不能解决问题。通过前面学到的知识, 我们可以通过分配分析器找出大部分垃圾产生的位置。实际上在此示例中, 99%的对象属于 Double 包装类, 在readSensor 方法中创建。最简单的优化, 将创建的 Double 对象替换为原生类型 double, 而针对 null 值的检测, 可以使用 Double.NaN 来进行。由于原生类型不算是对象, 也就不会产生垃圾, 导致GC事件。优化之后, 不在堆中分配新对象, 而是直接覆盖一个属性域即可。

对示例程序进行简单的改造( 查看diff ) 后, GC暂停基本上完全消除。有时候 JVM 也很智能, 会使用 逃逸分析技术(escape analysis technique) 来避免过度分配。简单来说,JIT编译器可以通过分析得知, 方法创建的某些对象永远都不会“逃出”此方法的作用域。这时候就不需要在堆上分配这些对象, 也就不会产生垃圾, 所以JIT编译器的一种优化手段就是: 消除内存分配。请参考 基准测试

过早提升(Premature Promotion)

提升速率(promotion rate), 用于衡量单位时间内从年轻代提升到老年代的数据量。一般使用 MB/sec 作为单位, 和分配速率类似。

JVM会将长时间存活的对象从年轻代提升到老年代。根据分代假设, 可能存在一种情况, 老年代中不仅有存活时间长的对象,也可能有存活时间短的对象。这就是过早提升:对象存活时间还不够长的时候就被提升到了老年代。

major GC 不是为频繁回收而设计的, 但 major GC 现在也要清理这些生命短暂的对象, 就会导致GC暂停时间过长。这会严重影响系统的吞吐量。

如何测量提升速率

可以指定JVM参数 -XX:+PrintGCDetails -XX:+PrintGCTimeStamps , 通过GC日志来测量提升速率. JVM记录的GC暂停信息如下所示:

0.291: [GC (Allocation Failure) 
        [PSYoungGen: 33280K->5088K(38400K)] 
        33280K->24360K(125952K), 0.0365286 secs] 
    [Times: user=0.11 sys=0.02, real=0.04 secs] 
0.446: [GC (Allocation Failure) 
        [PSYoungGen: 38368K->5120K(71680K)] 
        57640K->46240K(159232K), 0.0456796 secs] 
    [Times: user=0.15 sys=0.02, real=0.04 secs] 
0.829: [GC (Allocation Failure) 
        [PSYoungGen: 71680K->5120K(71680K)] 
        112800K->81912K(159232K), 0.0861795 secs] 
    [Times: user=0.23 sys=0.03, real=0.09 secs]

从上面的日志可以得知: GC之前和之后的 年轻代使用量以及堆内存使用量。这样就可以通过差值算出老年代的使用量。GC日志中的信息可以表述为:

EventTimeYoung decreasedTotal decreasedPromotedPromotion rate
(事件)(耗时)(年轻代减少)(整个堆内存减少)(提升量)(提升速率)
1st GC291ms28,192K8,920K19,272K66.2 MB/sec
2nd GC446ms33,248K11,400K21,848K140.95 MB/sec
3rd GC829ms66,560K30,888K35,672K93.14 MB/sec
Total829ms  76,792K92.63 MB/sec

根据这些信息, 就可以计算出观测周期内的提升速率。平均提升速率为 92 MB/秒, 峰值为 140.95 MB/秒

请注意, 只能根据 minor GC 计算提升速率。 Full GC 的日志不能用于计算提升速率, 因为 major GC 会清理掉老年代中的一部分对象。

提升速率的意义

和分配速率一样, 提升速率也会影响GC暂停的频率。但分配速率主要影响 minor GC, 而提升速率则影响 major GC 的频率。有大量的对象提升,自然很快将老年代填满。 老年代填充的越快, 则 major GC 事件的频率就会越高。

07_01_how-java-garbage-collection-works.png

此前说过, full GC 通常需要更多的时间, 因为需要处理更多的对象, 还要执行碎片整理等额外的复杂过程。

示例

让我们看一个过早提升的示例。 这个程序创建/获取大量的对象/数据,并暂存到集合之中, 达到一定数量后进行批处理:

public class PrematurePromotion {

   private static final Collection<byte[]> accumulatedChunks 
                = new ArrayList<>();

   private static void onNewChunk(byte[] bytes) {
       accumulatedChunks.add(bytes);

       if(accumulatedChunks.size() > MAX_CHUNKS) {
           processBatch(accumulatedChunks);
           accumulatedChunks.clear();
       }
   }
}

Demo 程序 受到过早提升的影响。下文将进行验证并给出解决办法。

过早提升的影响

一般来说,过早提升的症状表现为以下形式:

  • 短时间内频繁地执行 full GC。
  • 每次 full GC 后老年代的使用率都很低, 在10-20%或以下。
  • 提升速率接近于分配速率。

要演示这种情况稍微有点麻烦, 所以我们使用特殊手段, 让对象提升到老年代的年龄比默认情况小很多。指定GC参数 -Xmx24m -XX:NewSize=16m -XX:MaxTenuringThreshold=1, 运行程序之后,可以看到下面的GC日志:

2.176: [Full GC (Ergonomics) 
        [PSYoungGen: 9216K->0K(10752K)] 
        [ParOldGen: 10020K->9042K(12288K)] 
        19236K->9042K(23040K), 0.0036840 secs]
2.394: [Full GC (Ergonomics) 
        [PSYoungGen: 9216K->0K(10752K)] 
        [ParOldGen: 9042K->8064K(12288K)] 
        18258K->8064K(23040K), 0.0032855 secs]
2.611: [Full GC (Ergonomics) 
        [PSYoungGen: 9216K->0K(10752K)] 
        [ParOldGen: 8064K->7085K(12288K)] 
        17280K->7085K(23040K), 0.0031675 secs]
2.817: [Full GC (Ergonomics) 
        [PSYoungGen: 9216K->0K(10752K)] 
        [ParOldGen: 7085K->6107K(12288K)] 
        16301K->6107K(23040K), 0.0030652 secs]

乍一看似乎不是过早提升的问题。事实上,在每次GC之后老年代的使用率似乎在减少。但反过来想, 要是没有对象提升或者提升率很小, 也就不会看到这么多的 Full GC 了。

简单解释一下这里的GC行为: 有很多对象提升到老年代, 同时老年代中也有很多对象被回收了, 这就造成了老年代使用量减少的假象. 但事实是大量的对象不断地被提升到老年代, 并触发 full GC。

解决方案

简单来说, 要解决这类问题, 需要让年轻代存放得下暂存的数据。有两种简单的方法:

一是增加年轻代的大小, 设置JVM启动参数, 类似这样: -Xmx64m -XX:NewSize=32m, 程序在执行时, Full GC 的次数自然会减少很多, 只会对 minor GC的持续时间产生影响:

2.251: [GC (Allocation Failure) 
        [PSYoungGen: 28672K->3872K(28672K)] 
        37126K->12358K(61440K), 0.0008543 secs]
2.776: [GC (Allocation Failure) 
        [PSYoungGen: 28448K->4096K(28672K)] 
        36934K->16974K(61440K), 0.0033022 secs]

二是减少每次批处理的数量, 也能得到类似的结果. 至于选用哪个方案, 要根据业务需求决定。在某些情况下, 业务逻辑不允许减少批处理的数量, 那就只能增加堆内存,或者重新指定年轻代的大小。

如果都不可行, 就只能优化数据结构, 减少内存消耗。但总体目标依然是一致的: 让临时数据能够在年轻代存放得下。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值