分配速率过高就会严重影响程序的性能。在JVM中会导致巨大的GC开销。
指定JVM参数: -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
, 通过GC日志来计算分配速率. GC日志如下所示:
计算 上一次垃圾收集之后
,与下一次GC开始之前
的年轻代使用量, 两者的差值除以时间,就是分配速率。 通过上面的日志, 可以计算出以下信息:
- 在启动之后
446 ms
, 年轻代的使用量增加到38,368 KB
, 触发第二次GC, 完成后年轻代的使用量减少到 。 - 在启动之后
829 ms
, 年轻代的使用量为71,680 KB
, GC后变为5,120 KB
。
可以通过年轻代的使用量来计算分配速率, 如下表所示:
通过这些信息可以知道, 在测量期间, 该程序的内存分配速率为 161 MB/sec
。
分配速率的变化,会增加或降低GC暂停的频率, 从而影响吞吐量。 但只有年轻代的 受分配速率的影响, 老年代GC的频率和持续时间不受 分配速率(allocation rate
)的直接影响, 而是受到 提升速率(promotion rate
)的影响, 请参见下文。
经过我们的实验, 通过参数 -XX:NewSize
、 -XX:MaxNewSize
以及 -XX:SurvivorRatio
设置不同的 Eden 空间, 运行同一程序时, 可以发现:
- 将 Eden 区增大为
1 GB
, 分配速率也随之增长,大约等于200 MB/秒
。
为什么会这样? —— 因为减少GC暂停,就等价于减少了任务线程的停顿,就可以做更多工作, 也就创建了更多对象, 所以对同一应用来说, 分配速率越高越好。
在得出 “Eden区越大越好” 这个结论前, 我们注意到, 分配速率可能会,也可能不会影响程序的实际吞吐量。 吞吐量和分配速率有一定关系, 因为分配速率会影响 minor GC 暂停, 但对于总体吞吐量的影响, 还要考虑 Major GC(大型GC)暂停, 而且吞吐量的单位不是 MB/秒
, 而是系统所处理的业务量。
参考 Demo程序。假设系统连接了一个外部的数字传感器。应用通过专有线程, 不断地获取传感器的值,(此处使用随机数模拟), 其他线程会调用 processSensorValue()
方法, 传入传感器的值来执行某些操作, :
如同类名所示, 这个Demo是模拟 boxing 的。为了 null 值判断, 使用的是包装类型 Double
。 程序基于传感器的最新值进行计算, 但从传感器取值是一个重量级操作, 所以采用了异步方式: 一个线程不断获取新值, 计算线程则直接使用暂存的最新值, 从而避免同步等待。
在运行的过程中, 由于分配速率太大而受到GC的影响。下一节将确认问题, 并给出解决办法。
遇到这种情况时, GC日志将会像下面这样,当然这是上面的示例程序 产生的GC日志。 JVM启动参数为 -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xmx32m
:
很显然 minor GC 的频率太高了。这说明创建了大量的对象。另外, 年轻代在 GC 之后的使用量又很低, 也没有 full GC 发生。 种种迹象表明, GC对吞吐量造成了严重的影响。
在某些情况下,只要增加年轻代的大小, 即可降低分配速率过高所造成的影响。增加年轻代空间并不会降低分配速率, 但是会减少GC的频率。如果每次GC后只有少量对象存活, minor GC 的暂停时间就不会明显增加。
运行 时, 增加堆内存大小,(同时也就增大了年轻代的大小), 使用的JVM参数为 -Xmx64m
:
但有时候增加堆内存的大小,并不能解决问题。通过前面学到的知识, 我们可以通过分配分析器找出大部分垃圾产生的位置。实际上在此示例中, 99%的对象属于 Double
包装类, 在readSensor
方法中创建。最简单的优化, 将创建的 Double
对象替换为原生类型 , 而针对 null 值的检测, 可以使用 Double.NaN 来进行。由于原生类型不算是对象, 也就不会产生垃圾, 导致GC事件。优化之后, 不在堆中分配新对象, 而是直接覆盖一个属性域即可。
对示例程序进行( 查看diff ) 后, GC暂停基本上完全消除。有时候 JVM 也很智能, 会使用 逃逸分析技术(escape analysis technique) 来避免过度分配。简单来说,JIT编译器可以通过分析得知, 方法创建的某些对象永远都不会“逃出”此方法的作用域。这时候就不需要在堆上分配这些对象, 也就不会产生垃圾, 所以JIT编译器的一种优化手段就是: 消除内存分配。请参考 。