我有Iterable<T>
(四叉树结构的变体)的实现,我打算在大型数据集的性能至关重要的环境中使用,所以我一直在进行一些测试,几百万个随机条目,反复运行它们。我对以下代码段感到奇怪:
long start = System.currentTimeMillis();
for (int i = 0; i < 100; i++) {
Iterator<A> iter = it.iterator();
while (iter.hasNext()) {
iter.next();
}
}
long end = System.currentTimeMillis();
System.out.println("Total time: " + (end - start));
我总是有4000到5000毫秒的时间。但是,当我将while
循环更改为:
A a = null;
while (iter.hasNext()) {
a = iter.next();
}
时间跳跃 - 不仅仅是轻微的,而是一直到15到16秒,完全一致。现在这已经不依赖于next()
的实现了,但是经过进一步的调查,我发现它甚至发生在一个简单的ArrayList
上,所以我将发布可编译的代码:
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public class Test {
static class A {}
public static void main(String[] args) {
List<A> list = new ArrayList<>();
// Add a lot of entries
for (int i = 0; i < 10000000; i++) {
list.add(new A());
}
// Test it
A a = null;
Iterator<A> iter = null;
long start = System.currentTimeMillis();
for (int i = 0; i < 100; i++) {
iter = list.iterator();
while (iter.hasNext()) {
iter.next();
// Or:
// a = iter.next();
}
}
long end = System.currentTimeMillis();
System.out.println("Total time: " + (end - start));
}
}
结果:更令人难以置信的30倍差异。并且每次都会确定性地发生。
可能的原因是什么?我没有看到对已经分配的变量的单个赋值如何可以是除了可忽略的以外的任何值,特别是考虑到iter.next()
内发生了很多其他事情。我唯一的猜测是System.currentTimeMillis()
调用在某种程度上没有在适当的时间执行,但至于如何受到更改的影响,我不知道。
但即使这样也不太合适,因为它花费的时间明显多得多,特别是如果我进一步增加for
循环运行的次数。据我所知,垃圾收集器也不应该做任何事情,因为不应该发生浪费的临时分配。它显然是对返回值的赋值是至关重要的,因为除了iter.next()
之外只做其他事情,比如每次增加int
变量,都没有对执行时间产生同样的不利影响。
答案 0 :(得分:6)
我认为您所看到的许多差异将取决于您的基准测试方式。我没有看到您尝试处理JVM预热效果或隔离GC和内存分配效果的迹象。甚至是内存缓存大小的影响。
但我想我知道可能会发生什么。
之间的区别
while (iter.hasNext()) {
iter.next();
}
和
A a = null;
while (iter.hasNext()) {
a = iter.next();
}
(显然!)这项任务。但是赋值也有一个隐藏的类型转换来检查next()
返回的值是否真的是A
。 (提示:泛型类型擦除......)
但是类型演员怎么会花那么多时间?
嗯,我的理论是,这是类型转换本身的成本和内存缓存/位置效应的组合。
在第一个示例中,迭代是从大型数组中顺序读取引用。这是一个相对缓存友好的事情...因为数组将是内存中的单个连续块,并且硬件易于在单个操作中将多个字提取到缓存中。 (实际上,JIT 可能甚至会发出缓存预取指令......以避免流水线停滞。(这是猜测...))
在第二个例子中,在读取每个引用之间,CPU也将进行类型转换。类型转换涉及从每个A
实例的头中检索类标识符,然后测试它是否是正确的。
从对象标头中检索标识符是每次从不同部分内存中获取内存。对象可能在内存中开始连续,但即使如此,间距也可能是多个单词。缓存效果会差得多。即使数组和对象都通过相同的缓存这一事实也很重要。
测试类标识符可能非常重要。如果A
类不是接口而且没有子类,那么运行时应该能够执行==
测试的等效操作。否则,测试会更复杂,也更昂贵。
第二种可能的解释与代码内联有关。如果Iterator::next()
调用小到足以内联,那么JIT编译器的窥视孔优化器可能能够推断出部分或全部next
代码在分配中是多余的 - 更少版本的代码。但是,由于并发修改检查,我怀疑它可能推断next()
完全是多余的。消除这些检查会改变边缘情况下的代码行为,并且将是无效的优化。
简而言之,不难看出添加一个赋值和相关的隐藏词类型如何对性能产生重大影响,尤其是在大型数据结构上。