yuanzhixiang's blog

yuanzhixiang

ArrayList 原理剖析

2022-03-23

前言

在了解这个类之前,先了解它解决了什么问题。ArrayList 的出现是为了解决数组无法动态扩容的缺陷,使用 java 的数组需要在创建数组时便确定数组的容量,因为数组本身不会自动扩容。对于元素数量不确定的场景如果使用数组则需要预估所需的数组容量,如果估少了还需要重新申请空间并对原数组进行拷贝。这些代码逻辑在每一个用到数组的场景都需要重复进行处理,所以 ArrayList 对这部分重复编码的逻辑进行了封装,实现了动态扩容的效果。

既然 ArrayList 解决了数组扩缩容问题,那么其就相当于是一种创新型的数据结构,对应的它也需要解决好增删查改的问题,后面我们就从增删查改四个维度看一下它的实现方案。

增删查改

Add 数据的第一步是创建 ArrayList,其有三个构造函数,分别是用指定容量创建,无参构造创建和使用别的 Collection 来创建新的 ArrayList。这其中如果采用无参构造创建那么默认的 elementData 会被设置成 DEFAULTCAPACITY_EMPTY_ELEMENTDATA,这是一个空数组,你会发现它和 EMPTY_ELEMENTDATA 这个空数组被区分开了,其目的是为了判断出如果以无参构造创建 ArrayList,那么第一次添加元素时数组最小容量会被设置为 DEFAULT_CAPACITY,也就是下面这段代码控制。

private int newCapacity(int minCapacity) {
    // ...
    // 这里会判断是不是默认容量空数组,是的话最小容量会用 DEFAULT_CAPACITY
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
        return Math.max(DEFAULT_CAPACITY, minCapacity);
    // ...
}

了解完创建的逻辑,接下来就是新增,新增的逻辑中有几个关键变量,分别是 elementData, size, modCount, ArrayList 中使用 elementData 存放数据,size 指针表明当前数组中插入了多少个元素,同时也用于帮助新的数据插入数组尾部,modCount 用于控制数据版本,ArrayList 中只要对 elementData 进行了修改那么便会同时修改 modCount,确保迭代内不出现写入操作,防止迭代时数据出错。

如果在添加元素的时候出现元素长度不够的情况则会进行扩容,扩容时每次扩容 1.5 倍,由下面的代码计算扩容后的容量。

private int newCapacity(int minCapacity) {
    // ...
    // 这里的 oldCapacity >> 1 可以直接理解为老容量除 2
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    // ...
}

在扩容的代码里有一个有意思的方法是 hugeCapacity,仔细看代码会发现这里有一个 MAX_ARRAY_SIZE 常量定义了数组的最大长度为 Integer.MAX_VALUE - 8,之所以定义这个长度为数组的最大长度是因为某些虚拟机会在数组头寸写东西,所以如果超过这个长度就有可能出现 OutOfMemoryError,不过若是超过了这个长度,代码也会以 Integer.MAX_VALUE 去申请试试。

删除方法有三个,分别是按数组下标删除、按指定对象删除和按给定集合删除,第一种方式比较简单,直接通过下标删除元素,不过 ArrayList 内部的实现不是通过循环来实现,而是通过调用 System.arraycopy 方法来删除,效率更高。按对象删除则和第一种方式类似,其会先用循环找到元素下标,然后采用和第一种方式相同的方法删除元素。

最后一种按给定集合删除比较有意思,其内部采用了双指针的方式来删除数据,逻辑是先循环找到要第一个要删除的元素下标,保存该指针,然后第二个指针从该位置继续向后遍历,下一个元素要删除则继续找下一个,如果下一个不用删除则将其拷贝到第一个指针的位置,然后将第一个指针加一,以下是关键源代码。

boolean batchRemove(Collection<?> c, boolean complement,
    final int from, final int end) {
    final Object[] es = elementData;
    int r;
    // ...
    // 保存第一个指针为 w,r 为第二个指针
    int w = r++;
    try {
        // 这里便是双指针遍历拷贝数据
        for (Object e; r < end; r++) 
            if (c.contains(e = es[r]) == complement) 
                es[w++] = e;
    }
    // ...
    finally {
        modCount += end - w;
        // 最终将尾巴抹成 null
        shiftTailOverGap(es, w, end);
    }
    return true;
}

查找元素的方法有三个,分别是 get, indexOf, lastIndexOf, get 是直接按照数组下标找元素,indexOf 和 lastIndexOf 则是通过循环找元素然后返回数组下标。

对 ArrayList 的遍历有两种方式,一种是使用数组下标循环遍历,另一种则是使用 ArrayList 提供的迭代器进行遍历,ArrayList 提供了两种迭代器,分别是 Itr 和 ListItr,其中 ListItr 是继承 Itr 并在其中增加了一些方法,使其能够一边遍历一边 Add 元素。需要注意的是使用 Itr 遍历元素不能一边遍历一边修改数组内元素,否则 expectedModCount 与 modCount 不一致会导致报错,而使用 ListItr 则可以一边修改,这是因为 ListItr 修改元素后会更新 expectedModCount。

修改元素直接使用 set 方法,内部会直接根据下标修改数组元素。

优缺点

这样实现带来了动态扩容的优点,但其没有解决添加元素与元素扩容时需要拷贝元素的问题,如果想解决这类问题一般会使用 JDK 中的 LinkedList,不过好在我们实际的编程场景下绝大部分场景 ArrayList 的性能都优于 LinkedList,所以这些性能上的问题在使用时可以先忽略,如果在业务场景中出现了性能问题再针对对应的场景做优化。除了前面说的动态扩容,ArrayList 也继承了数组随机查找的优点,在通过数组下标查找元素的场景可以以 O(1) 的时间复杂度获取到数据。

常见问题

ArrayList 是不是线程安全的

ArrayList 不是线程安全的,判断一个类是不是线程安全是看类本身有没有状态,如果有状态那么还要看在修改这些状态的时候有没有上锁之类的操作保证修改这些状态确保不会出错。

另外如果想让 ArrayList 变成线程安全,可以使用 Collections.synchronizedList 进行包装,拿到的新 List 在 List 方法上均加了锁,需要注意的是新的 List 迭代器依然使用的是原集合的迭代器,所以其迭代器依然是线程不安全的,另一种方式是使用 CopyOnWriteArrayList 这个线程安全的 List。

总结

文章引言讲述了 ArrayList 诞生的原因以及其所解决的问题,之后分析了 ArrayList 内部的实现原理和优缺点,最后提了一些 ArrayList 中常见的问题,读完全文应该能对 ArrayList 的基本原理会有一定的认知,希望能对一些朋友产生帮助。