为什么Java HotSpot 在一次性调整大小后无法优化数组实例(导致大量性能损失)?

Why does Java HotSpot can not optimize array instance after one-time resizing (leads to massive performance loss)?

问题

为什么在附加的代码示例 (SELECT_QUICK = true) 中使用 fBuffer1 fBuffer2 时的另一个变体的两倍仅在开头调整一次大小 (SELECT_QUICK = false)?

代码路径完全相同,但即使在 10 分钟后,fBuffer2 的吞吐量也不会增加到 fBuffer1 的水平。

背景:

我们有一个通用的数据处理框架,它收集了不同子类中的数千个 Java 原始值(每个原始类型一个子类)。这些值在内部存储在数组中,我们最初将其大小设置得足够大。为了节省堆内存,我们现在将这些数组切换为动态调整大小(数组仅在需要时增长)。正如预期的那样,此更改已大大减少了堆内存。然而,另一方面,不幸的是,性能已经显着下降。我们的处理工作现在需要比以前长 2-3 倍的时间(例如 6 分钟而不是以前的 2 分钟)。

我已经将我们的问题简化为一个最小的工作示例并附上了它。您可以使用 SELECT_QUICK 选择应使用哪个缓冲区。我看到 jdk-1.8.0_202-x64openjdk-17.0.1-x64.

的效果相同

缓冲区 1(未调整大小)显示以下数字:

duration buf1: 8,890.551ms (8.9s)
duration buf1: 8,339.755ms (8.3s)
duration buf1: 8,620.633ms (8.6s)
duration buf1: 8,682.809ms (8.7s)
...

缓冲区 2(刚好在开始时调整大小 1 次)显示以下数字:

make buffer 2 larger
duration buf2 (resized): 19,542.750ms (19.5s)
duration buf2 (resized): 22,423.529ms (22.4s)
duration buf2 (resized): 22,413.364ms (22.4s)
duration buf2 (resized): 22,219.383ms (22.2s)
...

如果能得到一些提示,我将不胜感激,如何更改代码以使 fBuffer2(调整大小后)与 fBuffer1 一样快。反过来(让 fBuffer1fBuffer2 一样慢)也很容易。 ;-) 由于这个问题出现在一些类似框架的组件中,我宁愿更改代码而不是调整热点(使用外部参数)。但当然,非常欢迎双向的建议。

源代码

import java.util.Locale;

public final class Collector {

    private static final boolean SELECT_QUICK = true;

    private static final long LOOP_COUNT = 50_000L;
    private static final int VALUE_COUNT = 150_000;
    private static final int BUFFER_LENGTH = 100_000;

    private final Buffer fBuffer = new Buffer();

    public void reset() {fBuffer.reset();}
    public void addValueBuf1(long val) {fBuffer.add1(val);}
    public void addValueBuf2(long val) {fBuffer.add2(val);}

    public static final class Buffer {

        private int fIdx = 0;
        private long[] fBuffer1 = new long[BUFFER_LENGTH * 2];
        private long[] fBuffer2 = new long[BUFFER_LENGTH];

        public void reset() {fIdx = 0;}

        public void add1(long value) {
            ensureRemainingBuf1(1);
            fBuffer1[fIdx++] = value;
        }

        public void add2(long value) {
            ensureRemainingBuf2(1);
            fBuffer2[fIdx++] = value;
        }

        private void ensureRemainingBuf1(int remaining) {
            if (remaining > fBuffer1.length - fIdx) {
                System.out.println("make buffer 1 larger");
                fBuffer1 = new long[(fIdx + remaining) << 1];
            }
        }

        private void ensureRemainingBuf2(int remaining) {
            if (remaining > fBuffer2.length - fIdx) {
                System.out.println("make buffer 2 larger");
                fBuffer2 = new long[(fIdx + remaining) << 1];
            }
        }

    }

    public static void main(String[] args) {
        Locale.setDefault(Locale.ENGLISH);
        final Collector collector = new Collector();
        if (SELECT_QUICK) {
            while (true) {
                final long start = System.nanoTime();
                for (long j = 0L; j < LOOP_COUNT; j++) {
                    collector.reset();
                    for (int k = 0; k < VALUE_COUNT; k++) {
                        collector.addValueBuf1(k);
                    }
                }
                final long nanos = System.nanoTime() - start;
                System.out.printf("duration buf1: %1$,.3fms (%2$,.1fs)%n",
                    nanos / 1_000_000d, nanos / 1_000_000_000d);
            }
        } else {
            while (true) {
                final long start = System.nanoTime();
                for (long j = 0L; j < LOOP_COUNT; j++) {
                    collector.reset();
                    for (int k = 0; k < VALUE_COUNT; k++) {
                        collector.addValueBuf2(k);
                    }
                }
                final long nanos = System.nanoTime() - start;
                System.out.printf("duration buf2 (resized): %1$,.3fms (%2$,.1fs)%n",
                    nanos / 1_000_000d, nanos / 1_000_000_000d);
            }
        }
    }

}

HotSpot JVM 中的 JIT 编译是 1) 基于运行时配置文件数据; 2) 使用推测优化。

在最大优化级别编译该方法后,HotSpot 将停止分析此代码,因此无论代码运行多长时间都不会再重新编译。 (例外是当方法需要去优化或卸载时,但你的情况不是这样)。

在第一种情况下 (SELECT_QUICK == true),条件 remaining > fBuffer1.length - fIdx 永远不会满足,HotSpot JVM 通过在 收集的分析数据知道这一点。因此它推测性地将检查提升到循环之外,并在数组索引始终在边界内的假设下编译循环体。优化后,循环编译如下(伪代码):

if (VALUE_COUNT > collector.fBuffer.fBuffer1.length) {
    uncommon_trap();
}
for (int k = 0; k < VALUE_COUNT; k++) {
    collector.fBuffer.fBuffer1[k] = k;  // no bounds check
}

第二种情况(SELECT_QUICK == false),相反,HotSpot知道条件remaining > fBuffer2.length - fIdx有时满足,所以它不能取消检查。

因为 fIdx 不是循环计数器,HotSpot 显然不够聪明,无法将循环分成两部分(有和没有边界检查)。但是,您可以通过手动拆分循环来帮助 JIT 编译器:

for (long j = 0L; j < LOOP_COUNT; j++) {
    collector.reset();

    int fastCount = Math.min(collector.fBuffer.fBuffer2.length, VALUE_COUNT);
    for (int k = 0; k < fastCount; k++) {
        collector.addValueBuf2Fast(k);
    }

    for (int k = fastCount; k < VALUE_COUNT; k++) {
        collector.addValueBuf2(k);
    }
}

其中 addValueBuf2Fast 插入一个没有边界检查的值:

    public void addValueBuf2Fast(long val) {fBuffer.add2Fast(val);}

    public static final class Buffer {
        ...
        public void add2Fast(long value) {
            fBuffer2[fIdx++] = value;
        }
    }

这应该会显着提高循环的性能:

make buffer 2 larger
duration buf2 (resized): 5,537.681ms (5.5s)
duration buf2 (resized): 5,461.519ms (5.5s)
duration buf2 (resized): 5,450.445ms (5.5s)