使用 ArrayList 应当避免的坑

大家都知道 ArrayList 是由数组实现,而数据的长度有限,需要在合适的时机对数组扩容。

当我们初始化一个长度为 2 的 ArrayList ,并往里边写入三条数据时 ArrayList 就得扩容了,也就是将之前的数据复制一份到新的数组长度为 3 的数组中。

以下是扩容的源码,之所以是 3 ,是因为新的长度=原有长度 * 1.5

private void grow(int minCapacity) {
    int oldCapacity = elementData.length;
    // 新容量为旧容量的1.5倍
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    // 如果新容量发现比需要的容量还小,则以需要的容量为准
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    // 如果新容量已经超过最大容量了,则使用最大容量
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // 以新容量拷贝出来一个新数组
    elementData = Arrays.copyOf(elementData, newCapacity);
}

通过源码我们可以得知 ArrayList 的默认长度为 10。

/**
 * 默认容量
 */
private static final int DEFAULT_CAPACITY = 10;

但其实并不是在初始化的时候就创建了 DEFAULT_CAPACITY = 10 的数组。而是在往里边 add 第一个数据的时候会扩容到 10。

private static int calculateCapacity(Object[] elementData, int minCapacity) {
    // 如果是空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA,就初始化为默认大小10
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        return Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    return minCapacity;
}

private void ensureExplicitCapacity(int minCapacity) {
    modCount++;

    if (minCapacity - elementData.length > 0)
        // 扩容
        grow(minCapacity);
}

既然知道了默认的长度为 10 ,那说明后续一旦写入到第九个元素的时候就会扩容为 10 * 1.5 = 15。这一步为数组复制,也就是要重新开辟一块新的内存空间存放这 15 个数组。一旦我们频繁且数量巨大的进行写入时就会导致许多的数组复制,这个效率是极低的。

但如果我们提前预知了可能会写入多少条数据时就可以提前避免这个问题。比如我们往里边写入 1000W 条数据,在初始化的时候就给定数组长度与用默认 10 的长度之间性能是差距巨大的。

用 JMH 基准测试验证一下。

JMH 是 Java Microbenchmark Harness 的缩写。中文意思大致是 “JAVA 微基准测试套件”。首先先明白什么是“基准测试”。

百度百科给的定义如下:

基准测试是指通过设计科学的测试方法、测试工具和测试系统,实现对一类测试对象的某项性能指标进行定量的和可对比的测试。

可以简单的类比成我们电脑常用的鲁大师,或者手机常用的跑分软件安兔兔之类的性能检测软件。都是按一定的基准或者在特定条件下去测试某一对象的的性能,比如显卡、IO、CPU之类的。

为什么要使用 JMH

基准测试的特质有如下几种:

  • 可重复性:可进行重复性的测试,这样做有利于比较每次的测试结果,得到性能结果的长期变化趋势,为系统调优和上线前的容量规划做参考。

  • 可观测性:通过全方位的监控(包括测试开始到结束,执行机、服务器、数据库),及时了解和分析测试过程发生了什么。

  • 可展示性:相关人员可以直观明了的了解测试结果(web界面、仪表盘、折线图树状图等形式)。

  • 真实性:测试的结果反映了客户体验到的真实的情况(真实准确的业务场景+与生产一致的配置+合理正确的测试方法)。

  • 可执行性:相关人员可以快速的进行测试验证修改调优(可定位可分析)。

可见要做一次符合特质的基准测试,是很繁琐也很困难的。外界因素很容易影响到最终的测试结果。特别对于 JAVA的基准测试。

你运行的次数与时间不同可能获得的结果也不同,很难获得一个比较稳定的结果。

对于这种情况,有一个解决办法就是大量的重复调用,并且在真正测试前还要进行一定的预热,使结果尽可能的准确。

除了这些,对于结果我们还需要一个很好的展示,可以让我们通过这些展示结果判断性能的好坏。

而这些JMH都有!

如何使用 JMH

JMH是 JDK9自带的,如果你是 JDK9 之前的版本也可以通过导入 openjdk

<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.19</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.19</version>
</dependency>

下面就用实例来演示一下:ArrayList 在初始化的时候就给定数组长度与用默认 10 的长度之间性能是差距巨大的。

@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
public class CollectionsTest {
    private static final int TEN_MILLION = 10000000;

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.MICROSECONDS)
    public void arrayList(){
        List<String> array = new ArrayList<>();
        for (int i = 0; i < TEN_MILLION; i++) {
            array.add("123");
        }
    }


    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.MICROSECONDS)
    public void arrayListSize(){
        List<String> array = new ArrayList<>(TEN_MILLION);
        for (int i = 0; i < TEN_MILLION; i++){
            array.add("123");
        }
    }


    public static void main(String[] args) throws RunnerException{
        Options opt = new OptionsBuilder()
                .include(CollectionsTest.class.getSimpleName())
                .forks(1)
                .build();
        new Runner(opt).run();
    }
}

运行结果:

# Run complete. Total time: 00:00:23

Benchmark                      Mode  Cnt      Score       Error  Units
CollectionsTest.arrayList      avgt    5  50264.850 ±  6723.299  us/op
CollectionsTest.arrayListSize  avgt    5  38389.625 ± 14797.446  us/op

根据结果可以看出预设长度的效率会比用默认的效率高上很多(这里的 Score 指执行完函数所消耗的时间)。

所以这里强烈建议大家:在有大量数据写入 ArrayList 时,一定要初始化指定长度。

一定要慎用 add(int index, E element) 向指定位置写入数据,源码如下:

public void add(int index, E element) {
    // 检查是否越界
    rangeCheckForAdd(index);
    // 检查是否需要扩容
    ensureCapacityInternal(size + 1);
    // 将inex及其之后的元素往后挪一位,则index位置处就空出来了
    System.arraycopy(elementData, index, elementData, index + 1,
                     size - index);
    // 将元素插入到index的位置
    elementData[index] = element;
    // 大小增1
    size++;
}

通过源码我们可以看出,每一次写入都会将 index 后的数据往后移动一遍,其实本质也是要复制数组;但区别于往常规的往数组尾部写入数据,它每次都会进行数组复制,效率极低。

总结

高性能应用都是从小细节一点点堆砌起来的,就如这里提到的 ArrayList 的坑一样,日常使用没啥大问题,一旦数据量起来所有的小问题都会成为大问题。

  • 使用 ArrayList 时如果能提前预测到数据量大小,比较大时一定要指定其长度。
  • 尽可能避免使用 add(index,e) api,会导致复制数组,降低效率。
  • 再额外提一点,我们常用的另一个 Map 容器 HashMap 也是推荐要初始化长度从而避免扩容。

你可能感兴趣的:(java基础,java,后端)