JVM优化之分配速率

在实际的开发过程中,使用Java语言开发的应用基本上都会遇到性能问题,比如接口超时、服务器负载高、并发数低、数据库性能低或死锁等,并且现在随着互联网的发展,“猛快糙”的开发方式会让代码变得越来越臃肿,随着系统访问量的增加,各种性能问题就随之而来了。

应用的性能问题非常多,比如磁盘、内存、网络IO、应用代码、数据库、缓存、JVM等,有前辈总结过可以将Java性能优化分为4个层级:

  1. 应用层优化:也就是代码层,主要是代码上的优化,这个主要就要靠代码review和扎实的个人基础知识了,可以通过Java线程栈定位问题代码
  2. 数据库层优化:优化数据库读写方面的优化,分析SQL、定位死锁、分库分表
  3. 框架层优化:为应用选择合适的框架是最重要的,合适的框架能够带来更优的性能
  4. JVM层优化:JVM是应用的最底层,属于是最难也是最容易出现性能瓶颈的一层,GC、JVM参数合理使用

优化难度逐层增加,涉及的知识和解决的问题也不同,我们本文主要讲解一下JVM的年轻代GC方面的优化知识。


运行代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import java.util.concurrent.locks.LockSupport;

public class Boxing {

private static volatile Double sensorValue;

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

private static void processSensorValue(Double value) {
if (value != null) {
LockSupport.parkNanos(1000);
}
}

public static void main(String[] args) {
int iterations = args.length > 0 ? Integer.parseInt(args[0]) : 1_000_000;

initSensor();

for (int i = 0; i < iterations; i++) {
processSensorValue(sensorValue);
}
}

private static void initSensor() {
Thread sensorReader = new Thread(Boxing::readSensor);

sensorReader.setDaemon(true);
sensorReader.start();
}
}

JVM参数设置为-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xmx32m,运行代码,查看GC情况:

image-20200529013346025

这里先说一下什么叫分配速率(Allocation Rate),分配速率是指单位时间内分配的内存量,通常使用MB/sec作为单位,也可以使用PB/year来表示,分配速率过高就会严重影响程序的性能,在JVM中会导致巨大的GC开销

计算上一次GC之后与下一次GC之前的年轻代使用量,两者差值除以时间,就是分配速率

从上图GC日志中,我们计算一下信息:

  • 在JVM启动后391ms,共创建8704kb的对象,第一次YGC之后,年轻代中还有1004kb的存活对象
  • 在JVM启动后459ms,年轻代的使用量再次增加到9708kb,触发第二次YGC,GC之后年轻代的使用量缩减到1004kb
  • 在JVM启动后471ms,年轻代的使用量为9708kb,GC后为1020kb

然后我们现在来计算一下这三次GC的分配速率:

Event Time YGC before YGC after Allocated During Allocation Rate
1st YGC 391ms 8704kb 1004kb 8704kb 22MB/sec
2nd YGC 459ms 9708kb 1004kb 8704kb 51MB/sec
3rd YGC 471ms 9708kb 1020kb 8704kb 709MB/sec
Total 471ms 26112kb 55MB/sec

从表中我们看到,该程序的内存分配速率在55MB/sec。


分配速率的意义

分配速率的变化会增加或降低STW的频率,从而影响吞吐量,但仅仅只有年轻代的YGC会受分配速率的影响,老年代GC的频率和持续时间不收分配速率的直接影响,而是受到提升速率的影响,也就是Major GC是受Minor GC影响的。

我们知道年轻代中分为Eden、Survivor from和Survivor to三个区,因为分配速率直接影响Minor GC,所以我们先看下修改Eden的大小是否会减小Minor GC的频率,提升分配速率。

使用JVM参数-XX:NewSize-XX:MaxNewSize-XX:SurvivorRatio设置Eden和Survivor区的大小,我们将Eden区分别设置为100M和500M,看一下GC日志:

  • Eden区100M

    JVM参数:-XX:NewSize=125m -XX:MaxNewSize=125m -XX:SurvivorRatio=8

    image-20200529015605343

    Event Time YGC before YGC after Allocated During Allocation Rate
    1st YGC 686ms 102400kb 1967kb 102400kb 146MB/sec
    2nd YGC 820ms 104367kb 1548kb 102400kb 747MB/sec
    3rd YGC 947ms 103948kb 1548kb 102400kb 788MB/sec
    Total 947ms 307200kb 317MB/sec

    分配速率为317MB/sec

  • Eden区500M

    JVM参数:-XX:NewSize=625m -XX:MaxNewSize=625m -XX:SurvivorRatio=8

    image-20200529020327070

    Event Time YGC before YGC after Allocated During Allocation Rate
    1st YGC 1126ms 512000kb 1967kb 512000kb 445MB/sec
    2nd YGC 1752ms 513967kb 1836kb 512000kb 799MB/sec
    3rd YGC 2429ms 513836kb 1772kb 512000kb 739MB/sec
    Total 2429ms 1536000kb 618MB/sec

    分配速率为618MB/sec

随着Eden区大小越来越大,分配速率也越来越大,因为减少了GC频率,就等于减少了任务线程的停顿,就可以做更多的工作,也就创建了更多的对象,所以对于同一个Java应用来说,分配速率越高,性能越高。


高分配速率对JVM的影响

如果创建了过多的朝生夕死的对象,Minor GC的频率就会增加,在并发较大的情况下,会严重的影响吞吐量,从上面的三个场景可以看出来,当年轻代越大时Minor GC的次数就会越来越少,但是分配速率并没有降低,如果每次GC后只有少量的对象存活,Minor GC的暂停时间也不会明显的增加。

但是有时候增加年轻代的大小并不能彻底的解决问题,我们通过工具jvisualvm查看堆信息

image-20200529022229415

大部分堆内存都被Double对象占用了,这个对象是在readSensor()方法中创建的,最简单的代码层面的优化就是将包装类Double换成原生类型,因为原生类型不算是对象,所以也就不会在堆中分配内存,而是之间覆盖一个属性域即可,不会产生GC事件,所以GC基本上完全消除。并且JVM通过逃逸分析技术来避免过度分配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import java.util.concurrent.locks.LockSupport;

public class Boxing {

private static volatile double sensorValue = Double.NaN;

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

private static void processSensorValue(double value) {
if (Double.isNaN(value)) {
LockSupport.parkNanos(1000);
}
}

public static void main(String[] args) {
int iterations = args.length > 0 ? Integer.parseInt(args[0]) : 1_000_000;

initSensor();

for (int i = 0; i < iterations;) {
processSensorValue(sensorValue);
}
}

private static void initSensor() {
Thread sensorReader = new Thread(Boxing::readSensor);

sensorReader.setDaemon(true);
sensorReader.start();
}
}

image-20200529023002189

image-20200529023022780

控制台未输出任何GC日志,而jvisualvm上监控到的堆使用情况也极低,由此可见在代码中可以在适当情况下使用原生类型代替包装类。


总结

在年轻代使用上,应当适当的提高分配速率,减少Minor GC的频率,可以通过两种方式实现

  1. 增大新生代大小
  2. 使用原生类型代替包装类,减少堆内对象的创建

简单点说就是少创建对象、多分配空间,以减少GC次数,加大系统吞吐量