美文网首页
04-ArrayList 源码解析和设计思想(集合)

04-ArrayList 源码解析和设计思想(集合)

作者: xinxisimple | 来源:发表于2020-02-27 15:48 被阅读0次

    注:源码系列文章主要是对某付费专栏的总结记录。如有侵权,请联系删除。

    1 整体架构

    ArrayList 整体架构比较简单,就是一个数组结构,如下图:

    ArrayList 底层数组结构

    图中展示的是长度为 10 的数组,从 1 开始计数,index 表示数组的下标,从 0 开始计数,elementData 表示数组本身,源码中除了这两个概念,还有三个基本概念:

    • DEFAULT_CAPACITY 表示数组的初始大小,默认是 10;
    • size 表示当前数组的大小,类型是 int,没有使用 volatile 修饰,非线程安全;
    • modCount 统计当前数组被修改的版本次数,数组结构有变动,就会加 1。

    类注释

    看源码首先要看注释,我们看看类注释上面都说了哪些:

    • 允许 put null 值,会自动扩容;
    • size、isEmpty、get、set、add 等方法时间复杂度都是 O(1);
    • 是非线程安全的,多线程情况下,推荐使用线程安全类:Collections#synchronizedList;
    • 增加 for 循环,或者使用迭代器迭代过程中,如果数组大小被改变,会快速失败,抛出异常。

    2 源码解析

    2.1 初始化

    有三种初始化方法:

    • 无参数直接初始化
    • 指定大小初始化
    • 指定初始数据初始化
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
    
    // 无参数直接初始化
    public ArrayList() {
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }
    
    private static final Object[] EMPTY_ELEMENTDATA = {};
    
    // 指定大小初始化
    public ArrayList(int initialCapacity) {
        if (initialCapacity > 0) {
            this.elementData = new Object[initialCapacity];
        } else if (initialCapacity == 0) {
            this.elementData = EMPTY_ELEMENTDATA;
        } else {
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        }
    }
    
    // 指定初始数据初始化
    public ArrayList(Collection<? extends E> c) {
        elementData = c.toArray();
        if ((size = elementData.length) != 0) {
            // c.toArray might (incorrectly) not return Object[] (see 6260652)
            if (elementData.getClass() != Object[].class)
                elementData = Arrays.copyOf(elementData, size, Object[].class);
        } else {
            // replace with empty array.
            this.elementData = EMPTY_ELEMENTDATA;
        }
    }
    

    除了源码中的注释,我们再补充如下:

    1. ArrayList 无参构造器初始化时,默认大小是空数组,并不是默认的 DEFAULT_CAPACITY 10,10 是在第一次 add 的时候扩容的值 ArrayList.class#224 。
    2. 指定初始数据初始化时,我们发现一个这样子的注释 see 6260652,这是 Java 的一个 bug,意思是当给定集合内的元素不是 Object 类型时,我们会转化成 Object 类型(如:ArrayList 源码中的方法 public Object[] toArray() { // ... })。一般情况下不会触发此 bug,只有在下列场景下才会触发:ArrayList 初始化之后(元素指定非 Object 类型),再次调用 toArray 方法得到 Object 数组,并且往 Object 数组赋值时才会触发此 bug。演示如下:
     // 初始化具体类型(非 Object) 的集合
    List<String> list = Arrays.asList("a", "b");
    
    // 调用 toArray 返回 Object 类型数组
    Object[] array = list.toArray();
    System.out.println(array.getClass().getSimpleName()); // String[]
    // 虽然 toArray 返回的是 Object 数组,但是 array 元素的真实类型是 String
    // 这里存储 Object 类型时就会报错: java.lang.ArrayStoreException: java.lang.Object
    array[0] = new Object();
    

    执行结果:

    String[]
    java.lang.ArrayStoreException: java.lang.Object
        at com.example.ArrayListExample.toArrayObjectsTest(ArrayListExample.java:26)
    

    官方文档地址:<a href="https://bugs.java.com/bugdatabase/view_bug.do?bug_id=6260652">https://bugs.java.com/bugdatabase/view_bug.do?bug_id=6260652</a>,发现该问题已经在 Java 9 中被解决。

    2.2 新增和扩容实现

    新增主要分为两步:

    1. 判断是否需要扩容,如果需要则执行扩容操作;
    2. 赋值新元素。

    源码如下:

    public boolean add(E e) {
        // 确保数组大小是否足够,不够则执行扩容,size 为当前数组的大小
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        // 直接赋值
        elementData[size++] = e;
        return true;
    }
    

    扩容步骤(ensureCapacityInternal)源码:

    private void ensureCapacityInternal(int minCapacity) {
        ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
    }
    
    // 计算 capacity
    private static int calculateCapacity(Object[] elementData, int minCapacity) {
        // 如果数组为空,则返回默认容量(10) 和最小容量(比如初始化 List add 一个 element 时 minCapacity 为 1)之间的 max 值
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            return Math.max(DEFAULT_CAPACITY, minCapacity);
        }
        return minCapacity;
    }
    
    // 确保容量足够
    private void ensureExplicitCapacity(int minCapacity) {
        // 记录数组被修改 
        modCount++;
    
        // 如果我们期望的最小容量大于目前数组的长度,则执行扩容操作
        // overflow-conscious code
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }
    
    // 扩容,并把现有数据拷贝到新的数组中去
    private void grow(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        // 新的容量为旧的容量的 1.5 倍(capacity >> 1  ==> capacity / 2)
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        // 如果新的容量小于我们期望值 minCapacity,则将新的容量值赋值为期望值
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        // 如果新的容量大于 JVM 所能分配的数组的最大值,那么就用 Integer 的最大值
        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);
    }
    

    通过源码学习我们需要注意以下几点:

    1. 扩容的规则并不是翻倍,而是 原来容量大小 + 原来容量大小的一半,直白来说,扩容后的大小是原来容量的 1.5 倍;
    2. ArrayList 中数组的最大容量是 Integer.MAX_VALUE,超过这个值,JVM 就不会给数组分配内存空间了;
    3. 新增时,并没有对值进行严格的校验,所以 ArrayList 是允许 null 值的。

    在新增和扩容源码中,下面这点值得我们学习借鉴:

    • 源码在扩容的时候,有数组大小溢出意识,就是说扩容后的数组的大小下界不能小于 0,上界不能大于 Integer.MAX_VALUE,这种意识值得我们学习。(比如:二分查找计算 mid 时,不能使用 (low + high) / 2,而要使用 low + (high - low) / 2,因为 low + high 可能会超过 Integer 的最大值,JDK 之前有这个 bug)

    扩容完成之后,赋值是非常简单的,直接在数组上添加元素即可:elementData[size++] = e。也正是通过这种简单赋值,没有任何锁控制,所以这里的操作是线程不安全的。

    2.3 扩容的本质

    扩容是通过这行代码来实现的 Arrays.copyOf(elementData, newCapacity),这行代码描述的本质是数组之间的拷贝,扩容会先新建一个符合我们预期容量 newCapacity 的新数组,然后把旧数组的数据拷贝过去,源码如下:

    public static int[] copyOf(int[] original, int newLength) {
        int[] copy = new int[newLength];
        System.arraycopy(original, 0, copy, 0,
                         Math.min(original.length, newLength));
        return copy;
    }
    
    public static native void arraycopy(Object src,  int  srcPos, Object dest, int destPos, int length);
    

    测试如下:

    int[] elementData = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    System.out.println("旧的容量:" + oldCapacity);
    System.out.println("新的容量:" + newCapacity);
    
    System.out.println("旧的数组:" + JSON.toJSONString(elementData));
    // 执行扩容操作
    elementData = Arrays.copyOf(elementData, newCapacity);
    System.out.println("新的数组:" + JSON.toJSONString(elementData));
    

    执行结果:

    旧的容量:10
    新的容量:15
    旧的数组:[1,2,3,4,5,6,7,8,9,10]
    新的数组:[1,2,3,4,5,6,7,8,9,10,0,0,0,0,0]
    

    2.4 删除操作

    ArrayList 删除元素有很多种方式,比如根据数组索引删除、根据值删除或批量删除等等,原理和思路都差不多,我们选取根据值删除方式来进行源码分析:

    public boolean remove(Object o) {
        // 如果要删除的值是 null,找到第一个值是 null 的删除
        if (o == null) {
            for (int index = 0; index < size; index++)
                if (elementData[index] == null) {
                    fastRemove(index);
                    return true;
                }
        } else {
            // 如果要删除的不是 null,找到第一个和要删除的值相等的删除
            for (int index = 0; index < size; index++)
                if (o.equals(elementData[index])) {
                    fastRemove(index);
                    return true;
                }
        }
        return false;
    }
    

    注意两点:

    1. 新增的时候是没有对 null 进行校验的,所以删除的时候也是允许删除 null 值的;
    2. 找到值在数组中的索引位置,是通过 equals 来判断的,如果数组元素不是基本类型,需要我们关注 equals 的具体实现。

    上面源码中我们已经找到要删除元素的索引了,下面根据索引进行元素的删除:

    // 根据索引删除
    private void fastRemove(int index) {
        // 记录数组结构发生变化
        modCount++;
        // numMoved 表示删除 index 位置的元素后,需要从 index 后面移动多少个元素到前面去
        // 减 1 是因为 size 是从 1 开始计算的,index 是从 0 开始的,而我们调用 System.arraycopy 时需要的是 length 为数组元素被拷贝的长度值
        int numMoved = size - index - 1;
        if (numMoved > 0)
            // 从数组 index+1 位置开始拷贝,拷贝的起始位置是 index,长度是 numMoved
            System.arraycopy(elementData, index+1, elementData, index,
                             numMoved);
        // 数组size最后一个位置赋值为 null,帮助 GC
        elementData[--size] = null; // clear to let GC do its work
    }
    

    从源码中我们可以看出,某一个元素删除后,为了维护数组结构,我们都会把数组后面的元素往前移动。

    测试 demo:

    Integer[] elementData = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    int size = elementData.length;
    System.out.println("原始数据:" + JSON.toJSONString(elementData));
    System.out.println("数据长度:" + size);
    
    // 要删除的值
    int key = 5;
    // 要删除的值的索引
    int index = Arrays.binarySearch(elementData, key);
    System.out.println("要删除的元素值:" + key);
    System.out.println("要删除的元素索引位置:" + index);
    
    // numMoved: 删除一个值后,该值后面有多少个值需要往前移动
    // 比如这里删除值 5 之后,后面有 6,7,8,9,10  五个数据值需要移动
    // 旧的数组为 [1,2,3,4,5,6,7,8,9,10],删除成功后为 [1,2,3,4,6,7,8,9,10,null],而不是 [1,2,3,4,null,6,7,8,9,10]
    int numMoved = size - index - 1;
    System.out.println("numMoved:" + numMoved);
    if (numMoved > 0) {
        System.arraycopy(elementData, index+1, elementData, index, numMoved);
    }
    elementData[--size] = null;
    System.out.println("新的数据:" + JSON.toJSONString(elementData));
    System.out.println("数据长度:" + size);
    

    执行结果:

    原始数据:[1,2,3,4,5,6,7,8,9,10]
    数据长度:10
    要删除的元素值:5
    要删除的元素索引位置:4
    numMoved:5
    新的数据:[1,2,3,4,6,7,8,9,10,null]
    数据长度:9
    

    疑问:

    • 删除 index 为值的元素时,为何要将 index 后面的元素向前移动,再将最后一位赋值为 null。而不是不移动 index 后面的元素,直接赋值 index 位置为 null 呢?是为了让数组看起来更连续吗?

    2.5 迭代器

    如果要自己实现迭代器,实现 java.util.Iterator 类即可,ArrayList 也是这样做的,我们来看看迭代器的几个重要参数:

    // 迭代过程中,下一个元素的位置,默认从 0 开始
    int cursor;       // index of next element to return
    // 表示上一次迭代过程中,索引的位置;删除场景为 -1
    int lastRet = -1; // index of last element returned; -1 if no such
    // 表示迭代过程中,期望的版本号,modCount 表示数组实际的版本号
    int expectedModCount = modCount;
    

    迭代器的三个方法:

    • hasNext: 还有没有值可以迭代
    • next: 如果有值可以迭代,返回迭代的值
    • remove: 删除当前迭代的值

    三个方法的源码:

    hasNext

    public boolean hasNext() {
        // cursor 表示下一个元素的位置,size 表示实际大小,如果两者相等,说明已经没有元素可以迭代了,如果不等,说明还可以迭代
        // 例如当 size 为 10,cursor 为 0-9时都可以读取, 而如果 cursor 大于9,也就是数组最大值的下标
        return cursor != size;
    }
    

    next

    @SuppressWarnings("unchecked")
    public E next() {
        // 迭代过程中,判断版本号有无修改,有被修改,抛出 ConcurrentModificationException 异常
        checkForComodification();
        // 本次迭代过程中,元素的索引位置
        int i = cursor;
        // i 最大值只能是 size-1
        if (i >= size)
            throw new NoSuchElementException();
        Object[] elementData = ArrayList.this.elementData;
        if (i >= elementData.length)
            throw new ConcurrentModificationException();
        // 下一次迭代时,元素的位置,为下一次迭代做准备
        cursor = i + 1;
        // 返回元素值
        return (E) elementData[lastRet = i];
    }
    
    // 检查版本号
    final void checkForComodification() {
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }
    

    从源码可以看到,next 方法做了两件事,第一是检验能不能继续迭代,第二是找到迭代的值,并为下一次迭代做准备(cursor+1)。

    remove

    public void remove() {
        // 如果上一次操作时,数组的位置已经小于0了,说明数组已经被删除完了
        if (lastRet < 0)
            throw new IllegalStateException();
        // 迭代过程中,判断版本号有无被修改,有被修改,则抛出 ConcurrentModificationException 异常
        checkForComodification();
    
        try {
            // 删除当前迭代的元素
            // 为何是当前: 因为上面 next 中取当前值时已经为 lastRet 赋值为当前元素下标 `lastRet = i`
            ArrayList.this.remove(lastRet);
            // 因为删除了当前元素,而 ArrayList remove 方法删除元素后会将元素后面的所有元素向前移动,所以这里的下次迭代的位置 cursor 赋值为当前值的下标
            cursor = lastRet;
            // -1 表示元素已经被删除,这里也防止重复删除
            lastRet = -1;
            // 调用 ArrayList remove 删除元素时 modCount 已经发生变化,在此赋值给 expectedModCount
            // 这样下次迭代时,两者的值是一致的
            expectedModCount = modCount;
        } catch (IndexOutOfBoundsException ex) {
            throw new ConcurrentModificationException();
        }
    }
    

    这里我们需要注意:

    1. lastRet = -1 的操作目的,是防止重复删除操作;
    2. 删除元素成功,数组当前的 modCount 就会发生变化,这里会把 expectedModCount 重新赋值,这样下次迭代时两者的值就是一致的了。

    2.6 时间复杂度

    从我们上面新增或删除方法的源码解析,发现,对数组元素的操作,只需要根据数组索引,直接新增和删除,所以时间复杂度是 O(1)。

    2.7 线程安全

    我们需要强调的是,只有当 ArrayList 作为共享变量时,才会有线程安全问题,当 ArrayList 是方法内部局部变量时,是没有线程安全问题的。

    ArrayList 有线程安全问题的本质,是因为 ArrayList 自身的 elementData、size、modCount 在进行各种操作时,都没有加锁,而且这些变量的类型并非是可见的(volatile)的,所以如果多个线程对这些变量进行操作时,可能会有值被覆盖的情况。

    类注释中推荐我们使用 Collections#synchronizedList 来保证线程的安全,SynchronizedList 是通过在每个方法上面加上同步锁 synchronized 来实现的,虽然实现了线程安全,但是性能大大降低,具体实现源码:

    static class SynchronizedList<E> extends SynchronizedCollection<E>
        implements List<E> {
        private static final long serialVersionUID = -7754090372962971524L;
    
        final List<E> list;
    
        public void add(int index, E element) {
            // 使用 synchronized 轻量锁,mutex 表示当前 SynchronizedList,也就是父类 SynchronizedCollection 中的 mutex
            synchronized (mutex) {
                list.add(index, element);
            }
        }
    }
    

    总结

    从 ArrayList 整理架构触发,落地到初始化、新增、扩容、删除、迭代等核心源码实现,我们发现 ArrayList 其实就是围绕底层数组结构,各个 API 都是对数组的操作进行封装,让使用者无需感知底层实现,只需关注如何使用即可。

    ------------------------------------- END -------------------------------------

    相关文章

      网友评论

          本文标题:04-ArrayList 源码解析和设计思想(集合)

          本文链接:https://www.haomeiwen.com/subject/tpfxhhtx.html