验证ArrayList插入同样数据使用指定容量和默认容量的效率
之前在研究ArrayList源码的时候看到过一篇文章Java 8 容器源码-ArrayList里面说当ArrayList在进行插入的时候,如果容量不够那么就会进行自动扩容,扩容大小是现有容量的1.5倍,具体代码可以参考下面。
此处的默认容量是指当构建空的ArrayList构造函数时给分配的默认数组容量大小,为10。
/**
* 扩容,保证ArrayList至少能存储minCapacity个元素
* 第一次扩容,逻辑为newCapacity = oldCapacity + (oldCapacity >> 1);即在原有的容量基础上增加一半。第一次扩容后,如果容量还是小于minCapacity,就将容量扩充为minCapacity。
*
* @param minCapacity 想要的最小容量
*/
private void grow(int minCapacity) {
// 获取当前数组的容量
int oldCapacity = elementData.length;
// 扩容。新的容量=当前容量+当前容量/2.即将当前容量增加一半。
int newCapacity = oldCapacity + (oldCapacity >> 1);
//如果扩容后的容量还是小于想要的最小容量
if (newCapacity - minCapacity < 0)
//将扩容后的容量再次扩容为想要的最小容量
newCapacity = minCapacity;
//如果扩容后的容量大于临界值,则进行大容量分配
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData,newCapacity);
}
/**
所以在网上就会有许多的人说当知道了要用ArrayList存储多少数据的前提下,我们要指定ArrayList的容量大小,这样会减少ArrayList自动扩容的次数而增加效率。
此时相信大家心里就会和我有一样的疑问,在不同数据量的面前使用默认容量和指定容量大小效率到底会有多少的差别。相信有些人(比如我)一开始上来就会写如下的代码进行验证。
public class TestListAdd {
public static void main(String[] args){
System.out.println("Test 10000000 List add start");
System.out.println("Default Capacity: "+ listAdd(10000000));//默认容量
System.out.println("10000000 Capacity: "+ listAdd(10000000,10000000));//指定容量
}
public static Long listAdd(int num){
Long starTime = System.currentTimeMillis();
List<Object> list = new ArrayList<>();
for (Integer i = 0; i < num; i++) {
list.add(i);
}
Long endTime = System.currentTimeMillis();
return endTime - starTime;
}
public static Long listAdd(int capatity,int num){
Long starTime = System.currentTimeMillis();
List<Object> list = new ArrayList<>(capatity);
for (Integer i = 0; i < 10000000; i++) {
list.add(i);
}
Long endTime = System.currentTimeMillis();
return endTime - starTime;
}
}
结果当然也是符合预期的,下面的输出比上面的要少许多
Test 10000000 List add start
Default Capacity: 2416
10000000 Capacity: 397
如果试验到此为止我也就不会写这篇博客了,因为没什么可写的了,但是当我将上面默认容量大小和指定容量大小两行代码换个位置,那么结果就会与我们所想的不相同。
Test 10000000 List add start
10000000 Capacity: 2130
Default Capacity: 933
经过查询各种资料,中间走了许多的坑,例如一开始我想着是第一次的调用会将int从0-10000000的数第一次创建到常量池里面,而第二次就不用创建直接去常量池里面去取第一次创建好的int类型就好了,所以每次的第一次调用会比第二次调用速度要慢许多。当然随后经过学习查资料,否定了我第一次的蠢想法。两个不同的方法内的变量根本不会有交互。随后看到了一篇文章和我的问题差不多java循环长度的相同、循环体代码相同的两次for循环的执行时间相差了100倍?里面的回答提到了一句话。
在HotSpot VM上跑microbenchmark切记不要在main()里跑循环计时就完事。这是典型错误。重要的事情重复三遍:请用JMH,请用JMH,请用JMH。除非非常了解HotSpot的实现细节,在main里这样跑循环计时得到的结果其实对一般程序员来说根本没有任何意义,因为无法解释。
当然里面的其他术语我是看不懂的,只是简单的明白了两点
- 使用main方法进行微测一个方法的执行效率是不对的。具体怎么不对,我也不清楚,看不懂,里面的答案有说明。
- 要使用JMH、要使用JMH、要使用JMH。
JMH 是 Java Microbenchmark Harness(微基准测试)框架的缩写(2013年首次发布)。与其他众多测试框架相比,其特色优势在于它是由 Oracle 实现 JIT 的相同人员开发的。
我会在后面贴出一些学习JMH的经典文章。下面就开始我们的测试吧。
测试准备
使用JMH进行测试十分的简单,只需要加入一些注解即可。他就能屏蔽一些JVM对于测试的影响。让我们只关注于测试结果。
@Benchmark
public static void listAdd(Blackhole blackhole){
List<Object> list = new ArrayList<>(10000);
for (Integer i = 0; i < 10000; i++) {
list.add(i);
}
blackhole.consume(list);
}
我的JMH的配置如下
- warmup iterations的数量: 10
- iterations的数量:10
- forks的数量:2
- threads的数量:1
- 时间:TimeUnit.NANOSECONDS
- Mode:Mode.AverageTime
具体的测试代码我就不展示了,其中无非就是循环次数和初始化的容量。我就直接将数据展示出来吧。
循环次数 | 初始化容量 | 平均时间(微秒) |
---|---|---|
10 | 10 | 55.38 |
100 | 79.06 | |
1000 | 361.85 | |
10000 | 2355.82 |
循环次数 | 初始化容量 | 平均时间(微秒) |
---|---|---|
100 | 10 | 524.50 |
100 | 506.01 | |
1000 | 851.23 | |
10000 | 2804.22 |
循环次数 | 初始化容量 | 平均时间(微秒) |
---|---|---|
1000 | 10 | 7233.56 |
100 | 6923.32 | |
1000 | 5284.91 | |
10000 | 7723.32 |
循环次数 | 初始化容量 | 平均时间(微秒) |
---|---|---|
10000 | 10 | 81188.92 |
100 | 64393.20 | |
1000 | 59316.76 | |
10000 | 51382.20 |
下面是根据上面数据画的折线图,能够更加直观的感受到变化。
总结
大概由上面的试验,我们能够得出以下结论
- 插入总数一定时,当初始化容量<插入总数。那么初始化容量越大效率越高。
- 插入总数一定时,当初始化容量>插入总数。那么初始化容量越大效率越低。
尤其这种效率的提升在数据量大的时候更为明显,因为数据量大而导致初始化容量不够,扩容次数不断的增加。导致效率降低。