我必须承认这篇文章的标题有点吸引人。 我最近阅读了此博客文章 ,这是有关此主题的讨论和辩论的一个很好的摘要。
但是这次,我想尝试一种不同的方法来比较这两个众所周知的数据结构:使用硬件性能计数器 。
我不会进行微基准测试,也不能直接进行。 我不会使用System.nanoTime()计时,而是使用HPC(例如高速缓存命中/未命中)。
无需介绍这些数据结构,每个人都知道它们的用途以及实现方式。 我将研究重点放在列表迭代上,因为除了添加元素之外,这是列表最常见的任务。 同时也因为列表的内存访问模式是CPU缓存交互的一个很好的例子。
这是我的用于测量LinkedList和ArrayList的列表迭代的代码:
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;import ch.usi.overseer.OverHpc;public class ListIteration
{private static List<String> arrayList = new ArrayList<>();private static List<String> linkedList = new LinkedList<>();public static void initializeList(List<String> list, int bufferSize){for (int i = 0; i < 50000; i++){byte[] buffer = null;if (bufferSize > 0){buffer = new byte[bufferSize];}String s = String.valueOf(i);list.add(s);// avoid buffer to be optimized awayif (System.currentTimeMillis() == 0){System.out.println(buffer);}}}public static void bench(List<String> list){if (list.contains("bar")){System.out.println("bar found");}}public static void main(String[] args) throws Exception{if (args.length != 2) return;List<String> benchList = "array".equals(args[0]) ? arrayList : linkedList;int bufferSize = Integer.parseInt(args[1]);initializeList(benchList, bufferSize);HWCounters.init();System.out.println("init done");// warmupfor (int i = 0; i < 10000; i++){bench(benchList);}Thread.sleep(1000);System.out.println("warmup done");HWCounters.start();for (int i = 0; i < 1000; i++){bench(benchList);}HWCounters.stop();HWCounters.printResults();HWCounters.shutdown();}
}
为了进行测量,我使用基于监督程序库的名为HWCounters的类来获取硬件性能计数器。 您可以在这里找到此类的代码。
该程序采用2个参数:第一个参数用于ArrayList实现或LinkedList之间的选择,第二个参数用于initializeList
方法中使用的缓冲区大小。 此方法使用50K字符串填充列表实现。 每个字符串都是刚创建的,即将添加到列表中。 我们也可以根据程序的第二个参数分配一个缓冲区。 如果为0,则不分配缓冲区。
bench
方法执行对列表中未包含的常量字符串的搜索,因此我们完全遍历了列表。
最后, main
方法是执行列表的初始化,对基准方法进行预热并测量该方法的1000次运行。 然后,我们从HPC打印结果。
让我们在不带2 Xeon X5680的Linux上不分配缓冲区的情况下运行程序:
[root@archi-srv]# java -cp .:overseer.jar com.ullink.perf.myths.ListIteration array 0
init done
warmup done
Cycles: 428,711,720
Instructions: 776,215,597
L2 hits: 5,302,792
L2 misses: 23,702,079
LLC hits: 42,933,789
LLC misses: 73
CPU migrations: 0
Local DRAM: 0
Remote DRAM: 0
[root@archi-srv]# /java -cp .:overseer.jar com.ullink.perf.myths.ListIteration linked 0
init done
warmup done
Cycles: 767,019,336
Instructions: 874,081,196
L2 hits: 61,489,499
L2 misses: 2,499,227
LLC hits: 3,788,468
LLC misses: 0
CPU migrations: 0
Local DRAM: 0
Remote DRAM: 0
第一次运行是在ArrayList实现上,第二次是使用LinkedList。
- 周期数是执行代码所花费的CPU周期数。 显然,LinkedList比ArrayList花费了更多的周期。
- LinkedList的说明要高一些。 但这在这里并不重要。
- 对于L2缓存访问,我们有一个明显的区别:与LinkedList相比,ArrayList的L2未命中率要高得多。
- 从机械上讲,LLC命中对ArrayList非常重要。
进行此比较的结论是,列表迭代期间访问的大多数数据位于LinkedList的L2中,但位于ArrayList的L3中。
我对此的解释是,添加到列表中的字符串是在之前创建的。 对于LinkedList,这意味着它是本地的在添加元素时创建的Node条目。 我们在节点上有更多位置。
但是,让我们使用为每个新添加的String分配的中间缓冲区重新运行比较。
[root@archi-srv]# java -cp .:overseer.jar com.ullink.perf.myths.ListIteration array 256
init done
warmup done
Cycles: 584,965,201
Instructions: 774,373,285
L2 hits: 952,193
L2 misses: 62,840,804
LLC hits: 63,126,049
LLC misses: 4,416
CPU migrations: 0
Local DRAM: 824
Remote DRAM: 0
[root@archi-srv]# java -cp .:overseer.jar com.ullink.perf.myths.ListIteration linked 256
init done
warmup done
Cycles: 5,289,317,879
Instructions: 874,350,022
L2 hits: 1,487,037
L2 misses: 75,500,984
LLC hits: 81,881,688
LLC misses: 5,826,435
CPU migrations: 0
Local DRAM: 1,645,436
Remote DRAM: 1,042
这里的结果有很大的不同:
- 循环的重要性提高了10倍。
- 说明与以前相同
- 对于缓存访问,ArrayList具有比先前运行更多的L2未命中/ LLC命中,但仍处于相同的数量级顺序。 相反,LinkedList具有更多的L2未命中/ LLC命中,但此外,还有相当数量的LLC未命中/ DRAM访问。 区别就在这里。
使用中间缓冲区,我们可以推开条目和字符串,这会产生更多的高速缓存未命中,并且最终还会访问DRAM,这比访问高速缓存要慢得多。
ArrayList在这里更可预测,因为我们彼此之间保持元素的局部性。
此处的内存访问模式对于列表迭代性能至关重要。 ArrayList比LinkedList更稳定,因为在每个元素添加之间进行任何操作,都可以使数据保持比LinkedList更本地。
还要记住,对数组进行迭代对于CPU而言效率要高得多,因为它可以触发硬件预取,因为访问模式是非常可预测的。
翻译自: https://www.javacodegeeks.com/2013/12/arraylist-vs-linkedlist.html