声明多个长度为64的数组比声明一个长度为65的数组快1000倍。

92

最近我注意到声明一个包含64个元素的数组比声明同类型、65个元素的数组要快很多(速度超过1000倍)。

以下是我用来测试的代码:

public class Tests{
    public static void main(String args[]){
        double start = System.nanoTime();
        int job = 100000000;//100 million
        for(int i = 0; i < job; i++){
            double[] test = new double[64];
        }
        double end = System.nanoTime();
        System.out.println("Total runtime = " + (end-start)/1000000 + " ms");
    }
}

如果我将new double[64]替换为new double[65],运行时间将约为7秒,而如果是double[64]大于约6毫秒。如果任务跨越多个线程,则此问题会成倍加重,这也是我的问题所在。

不同类型的数组(如int[65]String[65])也会出现此问题。但大型字符串(如String test = "many characters";)则不会发生此问题,但当将其更改为String test = i + "";时,问题开始出现。

我想知道为什么会出现这种情况,以及是否可能规避此问题。


3
注:进行基准测试时,应优先使用 System.nanoTime() 而不是 System.currentTimeMillis() - rocketboy
4
我很好奇,你是在Linux下运行吗?行为会因操作系统而改变吗? - bsd
9
这个问题怎么会被踩票了? - Rohit Jain
2
就我所知,如果我使用“byte”而不是“double”来运行此代码,我会看到类似的性能差异。 - Oliver Charlesworth
3
那么,是什么解释了OP实验中的差异? - Oliver Charlesworth
显示剩余22条评论
2个回答

89
您正在观察由Java虚拟机(JIT编译器进行的)优化引起的行为。此行为可通过标量数组触发,最多64个元素,对大于64个元素的数组不会触发。
在深入了解之前,让我们更仔细地看一下循环体:
double[] test = new double[64];

该语句对程序外部没有影响(可观察行为)。这意味着执行或不执行此语句对程序的运行结果没有影响。整个循环同理。因此,代码优化器可能会将循环转换为某些(或无)具有相同功能但不同时间行为的内容。

进行基准测试时,至少应遵循以下两个指南。如果您这样做了,差异将会明显减小。

  • 通过多次执行基准测试来预热JIT编译器(和优化器)。
  • 使用每个表达式的结果并在基准测试结束时打印它。

现在我们来详细讨论一下。毫不奇怪,对于不超过64个元素的标量数组会触发一种优化。这种优化是逃逸分析的一部分。它将小对象和小数组放到堆栈上,而不是在堆上分配它们 - 或者更好地完全优化掉它们。您可以在Brian Goetz于2005年撰写的以下文章中找到有关此信息的一些信息: 可以使用命令行选项-XX:-DoEscapeAnalysis禁用优化。标量数组的神奇值64也可以在命令行上更改。如果按以下方式执行程序,则64个和65个元素的数组之间没有区别:
java -XX:EliminateAllocationArraySizeLimit=65 Tests

话虽如此,我强烈不建议使用这样的命令行选项。我怀疑在实际应用中它是否会有很大的区别。只有在我绝对确信有必要时才会使用它,而不是基于一些伪基准测试结果。


9
为什么优化器能够检测到大小为64的数组是可移除的,但却不能检测到大小为65的数组呢? - ug_
10
虽然OP的代码可能并不现实,但它显然在JVM中触发了一种有趣/意想不到的行为,这可能会对其他情况产生影响。我认为询问为什么会发生这种情况是有道理的。 - Oliver Charlesworth
1
@ThomasJungblut 我认为循环没有被删除。你可以在循环外添加 "int total",并在上面的示例中添加 "total += test[0];"。然后打印结果,你会发现 total = 1亿,并且仍然在不到一秒的时间内运行。 - Sipko
1
On Stack Replacement(OSR)是指在运行时将解释代码实时替换为编译代码,而不是将堆分配替换为栈分配。EliminateAllocationArraySizeLimit 是逃逸分析中被视为可替代的标量数组的大小限制。因此,对于编译器优化所产生的影响,关键点是正确的,但并非由于栈分配,而是由于逃逸分析阶段未能注意到分配是不必要的。 - kiheru
2
@Sipko:你写道应用程序在使用线程数量时无法扩展。这表明问题与你所询问的微观优化无关。我建议你看整体而非局部。 - nosid
显示剩余11条评论

2

根据对象的大小,有很多种可能导致差异。

正如nosid所述,JITC可能会(最有可能)在堆栈上分配小型“本地”对象,并且“小型”数组的大小截止值可能为64个元素。

在堆栈上分配比在堆中分配要快得多,而且更重要的是,堆栈不需要进行垃圾回收,因此GC开销大大降低。(对于这个测试用例,GC开销可能占总执行时间的80-90%。)

此外,一旦值被堆栈分配,JITC就可以执行“死代码消除”,确定new的结果在任何地方都没有使用过,并在保证没有失去的副作用的情况下,消除整个new操作,然后再消除(现在为空的)循环本身。

即使JITC不进行堆栈分配,对于小于某个大小的对象,也有可能以与较大对象不同的方式(例如,来自不同的“空间”)在堆中分配。(虽然通常这不会产生如此明显的时间差异。)


来晚了。为什么在堆栈上分配比在堆上分配更快?根据一些文章,堆上的分配需要约12个指令。改进的空间不大。 - Vortex
@Vortex - 在堆栈上分配需要1-2条指令。但是这是为了分配整个堆栈帧。无论如何,必须分配堆栈帧以便为例程保存寄存器。因此,同时分配的任何其他变量都是“免费”的。而且正如我所说,堆栈不需要GC。与堆分配操作的成本相比,堆项的GC开销要大得多。 - Hot Licks

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接