1. 概述
Java编程语言提供数组和集合来将对象组合在一起。大多数情况下,集合由数组支持,并使用一组方法建模以处理它包含的元素。
在开发软件时,使用这两种数据结构是很常见的。因此,程序员需要一种桥接机制来将这些元素从一种形式转换为另一种形式。Arrays类的asList方法和Collection接口的toArray方法构成了这个桥梁。
在本教程中,我们将深入分析一个有趣的论点:使用哪种toArray方法以及为什么?我们还将使用JMH辅助基准测试来支持这些论点。
2. toArray兔子洞
在漫无目的地调用toArray方法之前,让我们先了解一下盒子里面是什么。Collection接口提供了两种将集合转换为数组的方法:
Object[] toArray()
<T> T[] toArray(T[] a)
这两种方法都返回一个包含集合中所有元素的数组。为了证明这一点,让我们创建一个naturalNumbers列表:
List<Integer> naturalNumbers = IntStream
.range(1, 10000)
.boxed()
.collect(Collectors.toList());
2.1 Collection.toArray()
toArray()方法分配一个新的内存数组,其长度等于集合的大小。在内部,它在支持集合的底层数组上调用Arrays.copyOf。因此,返回的数组没有对它的引用并且可以安全使用:
Object[] naturalNumbersArray = naturalNumbers.toArray();
但是,我们不能仅仅将结果转换为Integer[]。这样做会导致ClassCastException。
2.2 <T> T[] Collection.toArray(T[] a)
与非参数化方法不同,这个方法接收一个预分配的数组作为参数。此外,在方法的定义中使用泛型要求输入和返回的数组具有相同的类型。这也解决了之前观察到的遍历Object[]的问题。
此变体根据输入数组的大小以独特的方式工作:
-
如果预分配数组的长度小于集合的大小,则分配一个所需长度和相同类型的新数组:
Integer[] naturalNumbersArray = naturalNumbers.toArray(new Integer[0]);
-
如果输入数组大到足以包含集合的元素,则返回时包含这些元素:
Integer[] naturalNumbersArray = naturalNumbers.toArray(new Integer[naturalNumbers.size]);
现在,让我们回到最初的问题,即选择速度更快、表现更好的候选人。
3. 性能试验
让我们从一个简单的实验开始,该实验比较0大小(toArray(new T[0])和预大小(toArray(new T[size])变体。我们将使用流行的ArrayList和AbstractCollection支持的TreeSet进行试验。此外,我们将包括不同大小(小型、中型和大型)的集合,以获得广泛的样本数据。
3.1 JMH基准测试
接下来,让我们为我们的试验构建一个JMH(Java Microbenchmark Harness)基准测试。我们将为基准测试配置集合的大小和类型参数:
@Param({ "10", "10000", "10000000" })
private int size;
@Param({ "array-list", "tree-set" })
private String type;
此外,我们将为0大小和预大小toArray变体定义基准测试方法:
@Benchmark
public String[] zero_sized() {
return collection.toArray(new String[0]);
}
@Benchmark
public String[] pre_sized() {
return collection.toArray(new String[collection.size()]);
}
3.2 基准测试结果
在使用JMH(v1.28)和JDK(1.8.0_292)的8 vCPU、32GB RAM、Linux x86_64虚拟机上运行上述基准测试提供了如下所示的结果。分数揭示了每个基准测试方法的平均执行时间(以纳秒为单位)。
值越低,性能越好:
Benchmark (size) (type) Mode Cnt Score Error Units
<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
TestBenchmark.zero_sized 10 array-list avgt 15 24.939 ± 1.202 ns/op
TestBenchmark.pre_sized 10 array-list avgt 15 38.196 ± 3.767 ns/op
----------------------------------------------------------------------------------------------
TestBenchmark.zero_sized 10000 array-list avgt 15 15244.367 ± 238.676 ns/op
TestBenchmark.pre_sized 10000 array-list avgt 15 21263.225 ± 802.684 ns/op
----------------------------------------------------------------------------------------------
TestBenchmark.zero_sized 10000000 array-list avgt 15 82710389.163 ± 6616266.065 ns/op
TestBenchmark.pre_sized 10000000 array-list avgt 15 100426920.878 ± 10381964.911 ns/op
<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
TestBenchmark.zero_sized 10 tree-set avgt 15 66.802 ± 5.667 ns/op
TestBenchmark.pre_sized 10 tree-set avgt 15 66.009 ± 4.504 ns/op
----------------------------------------------------------------------------------------------
TestBenchmark.zero_sized 10000 tree-set avgt 15 85141.622 ± 2323.420 ns/op
TestBenchmark.pre_sized 10000 tree-set avgt 15 89090.155 ± 4895.966 ns/op
----------------------------------------------------------------------------------------------
TestBenchmark.zero_sized 10000000 tree-set avgt 15 211896860.317 ± 21019102.769 ns/op
TestBenchmark.pre_sized 10000000 tree-set avgt 15 212882486.630 ± 20921740.965 ns/op
仔细观察上述结果后,对于本次试验中的所有大小和集合类型,很明显0大小的方法调用全部获胜。
目前,这些数字只是数据。为了有一个详细的了解,让我们深入挖掘和分析它们。
3.3 分配率
假设地,可以假设由于优化了每个操作的内存分配,0大小的toArray方法调用比预大小的方法调用性能更好。让我们通过执行另一个基准测试并量化基准测试方法的平均分配率(每个操作分配的内存字节数)来阐明这一点。
JMH提供了一个GC分析器(-prof gc),它在内部使用ThreadMXBean#getThreadAllocatedBytes来计算每个@Benchmark的分配率:
Benchmark (size) (type) Mode Cnt Score Error Units
<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
TestBenchmark.zero_sized:·gc.alloc.rate.norm 10 array-list avgt 15 72.000 ± 0.001 B/op
TestBenchmark.pre_sized:·gc.alloc.rate.norm 10 array-list avgt 15 56.000 ± 0.001 B/op
---------------------------------------------------------------------------------------------------------------------------------
TestBenchmark.zero_sized:·gc.alloc.rate.norm 10000 array-list avgt 15 40032.007 ± 0.001 B/op
TestBenchmark.pre_sized:·gc.alloc.rate.norm 10000 array-list avgt 15 40016.010 ± 0.001 B/op
---------------------------------------------------------------------------------------------------------------------------------
TestBenchmark.zero_sized:·gc.alloc.rate.norm 10000000 array-list avgt 15 40000075.796 ± 8.882 B/op
TestBenchmark.pre_sized:·gc.alloc.rate.norm 10000000 array-list avgt 15 40000062.213 ± 4.739 B/op
<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
TestBenchmark.zero_sized:·gc.alloc.rate.norm 10 tree-set avgt 15 56.000 ± 0.001 B/op
TestBenchmark.pre_sized:·gc.alloc.rate.norm 10 tree-set avgt 15 56.000 ± 0.001 B/op
---------------------------------------------------------------------------------------------------------------------------------
TestBenchmark.zero_sized:·gc.alloc.rate.norm 10000 tree-set avgt 15 40055.818 ± 16.723 B/op
TestBenchmark.pre_sized:·gc.alloc.rate.norm 10000 tree-set avgt 15 41069.423 ± 1644.717 B/op
---------------------------------------------------------------------------------------------------------------------------------
TestBenchmark.zero_sized:·gc.alloc.rate.norm 10000000 tree-set avgt 15 40000155.947 ± 9.416 B/op
TestBenchmark.pre_sized:·gc.alloc.rate.norm 10000000 tree-set avgt 15 40000138.987 ± 7.987 B/op
显然,上述数字证明,对于相同的大小,分配率或多或少是相同的,无论集合类型或toArray变体如何。因此,它否定了任何推测性假设,即预大小和0大小的toArray变体由于内存分配率的不规则性而表现不同。
3.4 toArray(T[] a)内部结构
为了进一步找出问题的原因,让我们深入研究ArrayList的内部结构:
if (a.length < size)
return (T[]) Arrays.copyOf(elementData, size, a.getClass());
System.arraycopy(elementData, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
基本上,根据预分配数组的长度,它是Arrays.copyOf或原生System.arraycopy方法调用,将集合的基础元素复制到数组中。
此外,查看copyOf方法,很明显首先创建了一个长度等于集合大小的副本数组,然后是System.arraycopy调用:
T[] copy = ((Object)newType == (Object)Object[].class)
? (T[]) new Object[newLength]
: (T[]) Array.newInstance(newType.getComponentType(), newLength);
System.arraycopy(original, 0, copy, 0,
Math.min(original.length, newLength));
当0大小和预大小方法最终都调用原生System.arraycopy方法时,0大小方法调用速度如何更快?
神秘之处在于CPU时间的直接成本是在对外部预分配数组执行零初始化时花费的,这使得toArray(new T[size])方法的速度要慢得多。
4. 零初始化
Java语言规范指示新实例化的数组和对象应具有默认字段值,而不是内存中不规则的剩余值。因此,运行时必须将预分配的存储清零。基准测试实验证明,0大小数组方法调用设法避免归零,但预大小的情况却不能。
让我们考虑几个基准测试:
@Benchmark
public Foo[] arraycopy_srcLength() {
Object[] src = this.src;
Foo[] dst = new Foo[size];
System.arraycopy(src, 0, dst, 0, src.length);
return dst;
}
@Benchmark
public Foo[] arraycopy_dstLength() {
Object[] src = this.src;
Foo[] dst = new Foo[size];
System.arraycopy(src, 0, dst, 0, dst.length);
return dst;
}
实验观察表明,在arraycopy_srcLength基准测试中紧随数组分配的System.arraycopy能够避免dst数组的预置零。但是,arraycopy_dstLength的执行无法避免预置零。
巧合的是,后一种arraycopy_dstLength情况类似于预先确定大小的数组方法collection.toArray(new String[collection.size()]),其中无法消除归零,因此速度很慢。
5. 较新JDK的基准测试
最后,让我们在最近发布的JDK上执行原始基准测试,并将JVM配置为使用更新且改进的G1垃圾收集器:
# VM version: JDK 11.0.2, OpenJDK 64-Bit Server VM, 11.0.2+9
-----------------------------------------------------------------------------------
Benchmark (size) (type) Mode Cnt Score Error Units
-----------------------------------------------------------------------------------
ToArrayBenchmark.zero_sized 100 array-list avgt 15 199.920 ± 11.309 ns/op
ToArrayBenchmark.pre_sized 100 array-list avgt 15 237.342 ± 14.166 ns/op
-----------------------------------------------------------------------------------
ToArrayBenchmark.zero_sized 100 tree-set avgt 15 819.306 ± 85.916 ns/op
ToArrayBenchmark.pre_sized 100 tree-set avgt 15 972.771 ± 69.743 ns/op
###################################################################################
# VM version: JDK 14.0.2, OpenJDK 64-Bit Server VM, 14.0.2+12-46
------------------------------------------------------------------------------------
Benchmark (size) (type) Mode Cnt Score Error Units
------------------------------------------------------------------------------------
ToArrayBenchmark.zero_sized 100 array-list avgt 15 158.344 ± 3.862 ns/op
ToArrayBenchmark.pre_sized 100 array-list avgt 15 214.340 ± 5.877 ns/op
------------------------------------------------------------------------------------
ToArrayBenchmark.zero_sized 100 tree-set avgt 15 877.289 ± 132.673 ns/op
ToArrayBenchmark.pre_sized 100 tree-set avgt 15 934.550 ± 148.660 ns/op
####################################################################################
# VM version: JDK 15.0.2, OpenJDK 64-Bit Server VM, 15.0.2+7-27
------------------------------------------------------------------------------------
Benchmark (size) (type) Mode Cnt Score Error Units
------------------------------------------------------------------------------------
ToArrayBenchmark.zero_sized 100 array-list avgt 15 147.925 ± 3.968 ns/op
ToArrayBenchmark.pre_sized 100 array-list avgt 15 213.525 ± 6.378 ns/op
------------------------------------------------------------------------------------
ToArrayBenchmark.zero_sized 100 tree-set avgt 15 820.853 ± 105.491 ns/op
ToArrayBenchmark.pre_sized 100 tree-set avgt 15 947.433 ± 123.782 ns/op
####################################################################################
# VM version: JDK 16, OpenJDK 64-Bit Server VM, 16+36-2231
------------------------------------------------------------------------------------
Benchmark (size) (type) Mode Cnt Score Error Units
------------------------------------------------------------------------------------
ToArrayBenchmark.zero_sized 100 array-list avgt 15 146.431 ± 2.639 ns/op
ToArrayBenchmark.pre_sized 100 array-list avgt 15 214.117 ± 3.679 ns/op
------------------------------------------------------------------------------------
ToArrayBenchmark.zero_sized 100 tree-set avgt 15 818.370 ± 104.643 ns/op
ToArrayBenchmark.pre_sized 100 tree-set avgt 15 964.072 ± 142.008 ns/op
####################################################################################
有趣的是,toArray(new T[0])方法一直比toArray(new [size])更快。此外,它的性能随着JDK的每个新版本不断改进。
5.1 Java 11 Collection.toArray(IntFunction<T[]>)
在Java 11中,Collection接口引入了一个新的默认toArray方法,它接收一个IntFunction<T[]>生成器作为参数(一个将生成所需类型和提供的长度的新数组的方法)。
此方法通过调用值为零的生成器函数来保证new T[0]数组初始化,从而确保始终执行速度更快、性能更好的0大小toArray(T[])方法。
6. 总结
在本文中,我们探讨了Collection接口的不同toArray重载方法。我们还利用JMH微基准测试工具跨不同的JDK运行了性能试验。
我们了解归零的必要性和影响,并观察内部分配的数组如何消除归零,从而赢得性能竞赛。最后,我们可以坚定地得出总结,toArray(new T[0])变体比toArray(new T[size])更快,因此,当我们必须将集合转换为数组时,它应该始终是首选。
与往常一样,本教程的完整源代码可在GitHub上获得。