ArrayList-源码分析

159 阅读9分钟

1. ArrayList简介

最近慢慢习惯开始写博客,但是希望文章是以难-易-难-易的节奏来写,这样整个写作过程会轻松一点,同时也补档一下以前的一些知识,一举两得~

本篇是基于JDK1.8来讲解。

ArrayList本质是基于数组实现的,所以对它的操作基本都是围绕数组进行,接下来我们先看看它的继承关系。

al继承关系.png

我们通过其父类与接口去大致了解ArrayList拥有的能力

  • AbstractList/List ArrayList继承至AbstractList与实现了List接口,意味着ArrayList是一个有序可重复的集合,并且提供操作集合的方法。
  • RandomAccess 标识着该类支持快速随机访问。
  • Serializable 意味着该类拥有序列化与反序列化的能力。
  • Cloneable 意味着该类拥有浅拷贝能力

因为ArrayList存储能力的本质是基于数组来储存,所以我们需要对数组进行一个简单介绍,让童鞋们知道其优点和缺点,因为数组的优点和缺点,同时也是ArrayList的优点缺点。

1.1 数组简单介绍

数组是一种容器,创建数组时会在内存中创建一个连续的内存地址空间,这意味着数组的查询效率是十分快速的。

数组的特点:
  1. 整个数组的类型必须是统一的
  2. 数组的长度在运行时是不可改变的
  3. 空间存储上,内存地址是连续的
数组的优点:
  1. 查询效率十分高效
数组的缺点:
  1. 数组的长度不可改变,如果涉及到长度变化,需要用新长度的数组进行覆盖
  2. 随机增删效率较低,比如在中间添加元素等会比较麻烦

2.ArrayList特性

  1. ArrayList是有序、可重复的
  2. 查询性能较快(继承于数组的优点)
  3. 随机增删效率较低(继承于数组的缺点)
  4. 对比数组来说,ArrayList的长度是动态的
  5. 线程不安全

3.方法介绍

因为ArrayList是有序可重复的列表,所以本身数据结构并不复杂,其源码核心是操作数组,主要的焦点在于增删改查与扩容,所以我们研究ArrayList源码也是基于以上方向去阅读和理解。

3.1 类变量与构造函数介绍

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable{
    //序列化UID    
    private static final long serialVersionUID = 8683452581122892189L;
    //初始化容量
    private static final int DEFAULT_CAPACITY = 10;
    //用于非无参构造函数,在长度为0时指向该对象,不需要创建无用对象
    private static final Object[] EMPTY_ELEMENTDATA = {};
    //用于无参构造函数扩容时保证初始化容量为10
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
    //实际保存用的数组对象,用transient修饰,意味着该参数不参数序列化
    transient Object[] elementData; 
    //当前实际保存元素的数量
    private int size;
    //当前列表被改变的次数
    protected transient int modCount = 0;
 
    //构造函数-指定初始容量用,创建对应容量的数组,如果数量为0则指向一个默认静态空数组
    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);
        }
    }
    
    //构造函数-指定有初始化数据用,如果数据不为空的情况下,调用Arrays的copyOf,复制数据为一个新数组
    public ArrayList(Collection<? extends E> c) {
        Object[] a = c.toArray();
        if ((size = a.length) != 0) {
            if (c.getClass() == ArrayList.class) {
                elementData = a;
            } else {
                elementData = Arrays.copyOf(a, size, Object[].class);
            }
        } else {
            // replace with empty array.
            elementData = EMPTY_ELEMENTDATA;
        }
    }
    
    //构造函数,默认为空数组
    public ArrayList() {
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }

}

通过了解基本的成员变量与构造函数,我们得知了以下一点:

  1. 无参构造函数直接指向了DEFAULTCAPACITY_EMPTY_ELEMENTDATA这个默认静态数组
  2. 带有Collection参数的构造函数,会通过copyOf复制一个新数组
  3. 亦可以通过指定容量,创建一个空数组(在已知初始化容量时,鼓励通过此方式创建ArrayList)
  4. 通过无参构造函数创建的数组,第一次扩容时默认容量为10.

同时通过成员变量也带出疑问:

  1. DEFAULTCAPACITY_EMPTY_ELEMENTDATA 和 EMPTY_ELEMENTDATA的区别是什么?
  2. modCount的实际作用是什么?

同时一些实现的接口,目前我们也没有搞明白它的作用,比如:

  1. RandomAccess:是怎么支持快速随机访问的?
  2. Serializable:怎么做到序列化和反序列化的?
  3. Cloneable:是怎么进行拷贝的?

下面会逐渐搞清楚以上的疑问,各位童鞋耐心的往下读就可以了~

3.2 新增

在介绍新增的相关方法之前,必须再次强调数组的长度是不可变化的,那么ArrayList就必须解决新增时数组长度的问题,这里就会涉及到数组容量的扩容,另外就是在新增时候,ArrayList提供了随机位置新增新数据的能力,这里就会涉及的数组数据的位移,解决了这两个问题之后,基本就弄清楚新增的基本思路与逻辑是什么了。

3.2.1 add(T e)<尾部新增>

扩容我们会放在下一节来说,目前我们只需要了解扩容是为了保证数组的长度能支持加入新的元素。

public boolean add(E e) {
    //检查当前数据长度是否能支持添加新元素
    ensureCapacityInternal(size + 1); 
    //在当前实际尾部添加元素
    elementData[size++] = e;
    return true;
}
3.2.2 add(T e,int index)<随机位置新增>
public void add(int index, E element) {
    //判断index是否有效,如果是无效index,则抛出大名鼎鼎的越界错误
    if (index > size || index < 0)
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
    //检查当前数据长度是否能支持添加新元素
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    //这里对插入位置的数据进行右移一位,目标是为即将插入的下标腾出空位,让元素放在目标位置
    System.arraycopy(elementData, index, elementData, index + 1,
                     size - index);
    //将元素放在目标位置
    elementData[index] = element;
    //实际元素数量+1
    size++;
}
3.2.3 System.arraycopy的妙用-数据位移

这里我们再实际展开System.arraycopy实际的作用

比如我们目前有一个数组[1,2,3,4,5],

arr1.png

然后通过add(2,6)的方法,在下标2处添加6这个元素,那么此时的思路肯定要保证数据的有序性,我们要将下标与到尾部的数据,往后位移一位,给即将加入的新元素腾出空间。

arr2.png

腾出空间后我们再把新元素添加进去.

arr3.png

至此,在中间添加元素的逻辑完成,了解了这个逻辑后我们再来看看System.arraycopy的方法的参数

/**
src - 拷贝源数组
srcPos - 决定从源数组哪个位置开始拷贝
dest - 目标粘贴数组
destPos - 决定从粘贴数组的哪个位置开始粘贴
length - 决定拷贝与粘贴的数量
*/
public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length);

了解了该方法的参数后,我们再贴出add中位移的代码,做对比解释

System.arraycopy(elementData, copyStartIndex, elementData, copyStartIndex + 1,size - copyStartIndex);

我们可以看出,拷贝数组和粘贴数组都是源数组,那实际上就是自己操作自己的数据,先回顾一下位移思路。

  1. 找到需要位移的位置,就是第二个参数
  2. 找到位移后的位置,就是第三个参数,因为是单个元素,所以在原位置后面位移一位
  3. 有多少数据需要进行位移,就是最后一个参数

至此,就通过了System.arraycopy进行了位移的操作,实际的操作流程跟上图是一样的,不理解的童鞋可以根据步骤对比参数再细读一下。

3.2.4 add(Collection e,int index)<随机位置新增复数数据>
public boolean addAll(Collection<? extends E> c) {
    //通过Collection的toArray()方法,统一转成数组,这就说明了为什么其他类型的集合也能添加到ArrayList中
    Object[] a = c.toArray();
    //确定新复数元素的长度
    int numNew = a.length;
    //检查是否需要扩容
    ensureCapacityInternal(size + numNew); 
    //因为是复数,所以通过arraycopy直接批量复制到当前数组的尾端
    System.arraycopy(a, 0, elementData, size, numNew);
    //实际元素数量+N
    size += numNew;
    return numNew != 0;
}

还有个任意位置添加复数数据的方法,但是这个就不啰嗦重复说了,原理还是扩容+位移+批量复制,就当布置个作业给童鞋们自行研究。

3.3 扩容

上面讲新增中,暂时忽略了扩容的逻辑,接下来我们就详细看看扩容的逻辑


//扩容临界点
Int MAX_ARRAY_SIZE = Integer.MAX_VALUE-8//参数:minCapacity-新增后的总数量
//1. 确认当前数组是否通过无参构造函数创建,如果是的话则决定是否用默认容量扩容
private void ensureCapacityInternal(int minCapacity) {
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    //1.1 继续校验新增后的总数量是否需要扩容
    ensureExplicitCapacity(minCapacity);
}

//2. 校验新增后的总数量是否需要扩容
private void ensureExplicitCapacity(int minCapacity) {
    modCount++;
    //2.1 如果新增后的数量大于当前容量,则开始扩容
    if (minCapacity - elementData.length > 0)
        //2.2 开始扩容
        grow(minCapacity);
}

//3.实际的扩容逻辑
private void grow(int minCapacity) {
    //3.1 获取现数组长度
    int oldCapacity = elementData.length;
    //3.2 右位移一位相当于oldCapacity/2,所以这里实际上是扩容1.5倍
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    //3.3 判断扩容后数量是否超过Int最大值,如果超过则取新增数量为目标扩容数量
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    //3.4 再次进行判断,判断扩容数量,是否超过了Int最大值,是的话则需要判断是否OOM了
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    //3.5 创建一个新长度的数组,并且将数据进行迁移
    elementData = Arrays.copyOf(elementData, newCapacity);
}

//4.最大边缘的扩容策略,走到这里一步意味着数组长度接近极限
private static int hugeCapacity(int minCapacity) {
    /**
        4.1
        这里很多童鞋都可能想不明白minCapacity什么时候会小于0,
        假设当前数量等于Int的最大值,然后进行加法,此时超过数量就会突破Int最大值,变成了负数,由此来判断我们的值是否合法。
    */
    if (minCapacity < 0) 
        //当超过最大值,则直接抛出OOM
        throw new OutOfMemoryError();
    /**
        4.2
        这里其实做了个临界的缓冲策略,当数量大于了临界点MAX_ARRAY_SIZE后,
        扩容数量为Int最大值,如果接近临界点则扩容数量取临界点的数量
    */
    return (minCapacity > MAX_ARRAY_SIZE) ?
        Integer.MAX_VALUE :
        MAX_ARRAY_SIZE;
}

通过上面的源码我们稍微做个总结:

  1. 如果是通过无参构造函数创建的数组,在新增数量小于10时,那么它默认扩容容量为10,这就顺便区别了EMPTY_ELEMENTDATA 和 DEFAULTCAPACITY_EMPTY_ELEMENTDATA的作用
  2. 正常情况下扩容的数量为原本的1.5倍
  3. 如果涉及到从中间添加元素,则会利用 System.arraycopy进行位移,腾出对应的位移,然后再将新元素添加进去
  4. 扩容数量的极限为Int的最大值,超出后OOM

3.4 删除

ArrayList的删除,只关注删除后剩下的元素补位,也就是位移。

3.4.1 删除下标元素
public E remove(int index) {
    //判断下标是否越界
    if (index >= size)
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));

    modCount++;
    //获取删除的元素,用作返回
    E oldValue = (E) elementData[index];
    //算出删除后需要左移的数量
    int numMoved = size - index - 1;
    //判断是否需要左移
    if (numMoved > 0)
        //从删除下标的下一位开始到末尾,进行左移,填补空挡
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    //左移后,原本最后一位应为空,所以直接置空
    elementData[--size] = null;
    return oldValue;
}
3.4.2 删除对象

在删除对象中,第一步是遍历数组,找出对应的对象的下标,通过fastRemove删除。

public boolean remove(Object o) {
    //这里逻辑很简单,分空与非空,来决定如何对比对象,实际上都是找出对应的下标
    if (o == null) {
        for (int index = 0; index < size; index++)
            if (elementData[index] == null) {
                fastRemove(index);
                return true;
            }
    } else {
        for (int index = 0; index < size; index++)
            if (o.equals(elementData[index])) {
                fastRemove(index);
                return true;
            }
    }
    return false;
}

//这里的逻辑类似remove(int index),唯一不同就是不需要取出要删除的元素并返回
private void fastRemove(int index) {
    modCount++;
    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    elementData[--size] = null; 
}
3.4.3 删除集合

删除集合里面可能是唯一ArrayList稍微有点难度的知识点,这里会通过双指针算法进行元素的位移和清理。

//1.实际上是通过batchRemove来处理集合删除
public boolean removeAll(Collection<?> c) {
    Objects.requireNonNull(c);
    return batchRemove(c, false);
}
/**
参数:complement - true:保留交集,false:删除交集
*/
private boolean batchRemove(Collection<?> c, boolean complement) {
    final Object[] elementData = this.elementData;
    //1.这里其实使用了双指针,指针w是实际保存的进度,指针r是当前处理的进度
    int r = 0, w = 0;
    //2.数组是否有变化的标识
    boolean modified = false;
    try {
        //通过双指针开始遍历位移,保存需要保留的元素,关于这个双指针算法下面会放出图详细说
        for (; r < size; r++)
            if (c.contains(elementData[r]) == complement)
                elementData[w++] = elementData[r];
    } finally {
        //如果指针r没有处理到最后,则通过复制法将漏网之鱼放到后面(这里我也不太明白针对什么场景,有知道的童鞋可以在下面留言
        if (r != size) {
            System.arraycopy(elementData, r,
                             elementData, w,
                             size - r);
            w += size - r;
        }
        //如果w指针跟size不一致,则意味着有元素删除了
        if (w != size) {
            //因为通过双指针位移后,要处理后续位移后应该为空的下标
            for (int i = w; i < size; i++)
                elementData[i] = null;
            modCount += size - w;
            size = w;
            modified = true;
        }
    }
    return modified;
}

这里用到了双指针法去处理集合的删除,接下来我们上图来解释一下双指针的运作

比如目前我们拥有一个数组,如下 arr1.png

然后我们调用removeAll({2,4}),就是删除原集合中的2,4元素,流程如下

1. 判断第一个元素是否为2 or 4,判断为不是,arr[w]=arr[r],并且各自加一,执行结果如下 arsz1.png

2. 判断第二个元素是否2 or 4, 判断为是,则w不变,r++,执行结果如下 arsz2.png

3. 判断第三个元素是否2 or 4,是的话arr[w]=arr[r],并且各自加一,执行结果如下

arsz3.png

4. 判断第四个元素是否2 or 4, 判断为是,则w不变,r++,执行结果如下 arsz4.png

5. 判断第五个元素是否2 or 4,是的话arr[w]=arr[r],并且各自加一,执行结果如下 arsz5.png

整体这个算法其实没什么难度,不明白的童鞋看多几遍就会明白了

处理完后我们发现,指针w开始到结尾,都是一些脏数据需要重置,那么就理解了后续为何从w开始需要重置对应下标的数据操作了~

3.4.4 总结

删除操作只关心删除目标下标元素后的部位,而删除集合利用了双指针法来遍历位移与删除元素。

3.5 实现RandomAccess接口到底有什么用?

ArrayList实现了RandomAccess接口这件事,一开始我也很疑惑,因为RandomAccess长这个样子

public interface RandomAccess {
}

然后陷入沉思,翻阅了资料之后发现,这个只是一个身份标识用的接口,实现该接口意味着该类使用循环遍历会比用迭代器遍历速度要快,具体场景如下:

 public static void traverse(List list){
    if (list instanceof RandomAccess){
        //实现了RandomAccess接口,使用循环遍历
        for (int i = 0;i < list.size();i++){
            System.out.println(list.get(i));
        }
    }else{
        //没实现RandomAccess接口,使用迭代器
        Iterator it = list.iterator();
        while(it.hasNext()){
            System.out.println(it.next());
        }
    }
}

简单来说就是我们可以判断是否实现了该接口来决定用哪种循环方式。

3.6 序列化

ArrayList 基于数组实现,并且具有动态扩容特性,因此保存元素的数组不一定都会被使用,那么就没必要全部进行序列化。

保存元素的数组 elementData 使用 transient 修饰,该关键字声明数组默认不会被序列化。

transient Object[] elementData; 

ArrayList 实现了 writeObject() 和 readObject()来控制只序列化数组中有元素填充那部分内容。

private void writeObject(java.io.ObjectOutputStream s)
    throws java.io.IOException{
    //从序列化写入得知,modCount的作用是保证写入过程中数据不会被操作,如果写入过程中操作了则抛出错误
    int expectedModCount = modCount;
    
    s.defaultWriteObject();

    //写入实际元素的Size
    s.writeInt(size);

    //只写入有效范围内的元素
    for (int i=0; i<size; i++) {
        s.writeObject(elementData[i]);
    }
    //判断写入完后modCount有没改变
    if (modCount != expectedModCount) {
        throw new ConcurrentModificationException();
    }
}
private void readObject(java.io.ObjectInputStream s)
    throws java.io.IOException, ClassNotFoundException {
    elementData = EMPTY_ELEMENTDATA;
    s.defaultReadObject();
    s.readInt(); // ignored
    //读取size对应的对象到数组中
    if (size > 0) {
        ensureCapacityInternal(size);
        Object[] a = elementData;
        for (int i=0; i<size; i++) {
            a[i] = s.readObject();
        }
    }
}

4.与Vector的区别

  1. Vector与ArrayList十分类似,区别在于一个是线程安全,一个是线程不安全
  2. Vector 是同步的,因此开销就比 ArrayList 要大,访问速度更慢。最好使用 ArrayList 而不是 Vector,因为同步操作完全可以由程序员自己来控制;
  3. Vector 每次扩容请求其大小的 2 倍(也可以通过构造函数设置增长的容量),而 ArrayList 是 1.5 倍。

至此,ArrayList源码分析到此为止,看到这里的童鞋很了不起了~也希望你给作者点个赞,如果有错误的地方请在留言区支出,作者会积极的勘误并且进行修改。