JVM优化之提升速率

提升速率(promotion rate)是用于衡量单位时间内从新生代晋升到来年代的数据量,一般用MB/sec表示单位。JVM会将存活时间较长的对象从新生代提升到老年代,根据分代规则,老年代中不仅有存活时间长的对象,也有存活时间短的对象,这些存活时间短的对象的晋升过程就是过早提升,简单点讲就是对象存活时间尚未达到晋升年龄之前就被提升到了老年代。


测量提升速率

一般情况下我们需要通过GC日志来测量提升速率,我们来跑一段代码查看一下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;) {
processSensorValue(sensorValue);
}
}

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

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

image-20200529103423364

我们来分析一下这段GC日志,通过计算新生代使用量以及堆内存使用量,就可以通过差值算出老年代的使用量:

Event Time Young 整个堆减少内存 提升量 提升速率
1st GC 425ms 63553kb 63545kb 8kb 0.02MB/sec
2nd GC 508ms 66003kb 65995kb 8kb 0.02MB/sec
3rd GC 587ms 65488kb 65488kb 0kb 0MB/sec
Total 587ms 16kb 0.03MB/sec

从表格中我们看到平均的提升速率是0.03MB/sec,峰值是0.02MB/sec。

我们只能根据Minor GC计算提升速率,Full GC的日志不能用于计算提升速率,因为Major GC会清理掉老年代中的一部分对象,所以会计算不准确。


和分配速率JVM优化之分配速率一样,提升速率也会影响STW的频率,但分配速率主要影响Minor GC,而提升速率则影响Major GC的频率,若每次都有大量的对象从新生代晋升到老年代,那么老年代会很快被填满,老年代填充的越快,Major GC的频率就会越高。

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

  • 短时间内频繁的执行Full GC
  • 每次Full GC后老年代的使用率都很低
  • 提升速率接近于分配速率

jgc

我们来看一个过早提升的例子

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import java.util.ArrayList;
import java.util.Collection;

public class PrematurePromotion {

private static final int MAX_CHUNKS = Integer.getInteger("max.chunks", 10_000);

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();
}
}

public static void main(String[] args) {
while (true) {
onNewChunk(produceChunk());
}
}

private static byte[] produceChunk() {
byte[] bytes = new byte[1024];

for (int i = 0; i < bytes.length; i++) {
bytes[i] = (byte) (Math.random() * Byte.MAX_VALUE);
}

return bytes;
}

public static volatile byte sink;

public static void processBatch(Collection<byte[]> bytes) {
byte result = 0;

for (byte[] chunk : bytes) {
for (byte b : chunk) {
result ^= b;
}
}

sink = result;
}

}

运行的JVM参数:-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xmx24m -XX:NewSize=16m -XX:MaxTenuringThreshold=1,将晋升年龄设置为1

GC日志

image-20200529111456993

从日志中看到Full GC的频率很高,但是每次GC之后老年代的使用量都在减少,从日志上看是不是觉得和过早提升没多大关系?但是仔细想想,其实如果没有对象晋升到老年代或者晋升量很少的话,老年代的空间就不会不够用,也就不会被频繁的发生Full GC了,那么为什么老年代的使用量会减少呢?因为对象提升到老年代,同时老年代也有很多对象被回收,这就造成了老年代使用量减少的情况,但事实是大量的对象不断的提升到老年代,并触发Full GC。


优化策略

  1. 在JVM的整个GC流程里,Major GC和Full GC都依赖于Minor GC,可以简单的理解为都是由Minor GC触发的,那么我们就增大新生代的容量,让年轻代能放得下更多的对象,然后减少Minor GC的频率,这样的话Full GC的次数自然会被减少了。

    比如上面的代码,我们通过JVM参数-Xmx64m -XX:NewSize=32m来扩充整堆和新生代的大小,运行上述代码,查看GC日志信息:

    image-20200529120119214

    加大了新生代的大小,发现只发生了Minor GC,未触发Full GC,由此可见增加新生代的空间大小是可以减少Full GC

  2. 减少每次批处理的数量,但是此种情况要根据实际业务来决定

  3. 加大对象晋升年龄,防止对象过早晋升到老年代,可以通过JVM参数-XX:MaxTenuringThreshold=15来指定晋升年龄,但是新生代的对象晋升老年代并不一定非要等到最大年龄,比如当Survivor区的某个年龄的对象总量超过Survivor大小的一半时,大于等于这个年龄的所有对象都会被晋升到老年代

  4. 如果以上方案都不可以的话,就只能优化数据结构,减少内存消耗

以上方案的总体目标都是为了让年轻代能够放得下更多的对象。