ArrayList源码分析

  本系列的所有源码主要是针对JDK 1.8的版本来探讨,部分容器源码在1.7和1.8又较大变动(例如HashMap)才会进行对比。
  首先我们来看一下ArrayList的类图结构:
ArrayList继承关系图
  其中CloneableSerializableRandomAccess分别是属于给该类添加某个属性的接口,而核心内容应该是ListAbstractList的类继承和接口实现。
  接下来我们主要来围绕关于ArrayList的存储结构以及常用方法进行介绍,对于它的存储结构可以看到是:

/**
     * The array buffer into which the elements of the ArrayList are stored.
     * The capacity of the ArrayList is the length of this array buffer. Any
     * empty ArrayList with elementData == EMPTY_ELEMENTDATA will be expanded to
     * DEFAULT_CAPACITY when the first element is added.
     */
    private transient Object[] elementData;

  这里依据文档中解释我们可以获知的内容是:

  1. ArrayList的内部存储结构是已一个Object数组来作为容器存储所有数据;
  2. ArrayList在刚开始初始化时,如果未指定内部数组初始化长度,即通过new ArrayList<>()形式来声明的容器,在没有往其中插入数据的时候,内部elementData容器是一个空的数组,在第一个元素被插入的时候,会默认扩充容器容量大小为DEFAULT_CAPACITY(10),并且看源码可以知道内部还会维护一个size私有变量记录容器中元素的个数(注意这里说的是元素个数,而不是容器的容量)。
    以上是关于ArrayList的存储结构介绍,对于ArrayList主要提供了三个构造函数,接下来简单介绍一下构造函数部分的内容,分别是
    1. 无参构造:
/**
     * Constructs an empty list with an initial capacity of ten.
     *  这里说是初始化时初始容量为10,但是其实在不调用add方法插入数据,没有数据的时候默认是一个空 
     *  数组,而不是所谓的长度为10的数组,只有在初次插入时才会创建长度为10的数组,可以理解为延迟创建。
     */
    public ArrayList() {
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }
    1. 带初始化长度的构造:
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);
        }
    }

  一般建议如果提取能大致预估到容器中需要插入的数据大小范围,最好传入一个大小接近的值作为初始容器大小,这样可以减少容器扩容带来的性能消耗。

    1. Collection数组的构造:
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;
        }
    }

  以上就是主要的三个构造函数介绍,接下来围绕常用的增删改查方法的实现进行说明。

add

  对于ArrayList使用最为频繁必须当属add方法莫属了,那么对于这个新增方法的实现,有两种不同的重载方法实现,分别为add(E e)add(int index, E element)这两个,不过和构造函数类似,实现插入还可以通过addAll来实现直接插入某个集合到容器中,针对这些方法的不同实现,我们分别来进行介绍。

    1. add(E e)
      这个方法是一般用的最多的插入手段,源码如下:
public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // Increments modCount!!
        elementData[size++] = e;
        return true;
    }

  既然是用的最多的方法,那么我们有必要对这个方法进行深入挖掘,方法主体结构逻辑十分简单,就是先调用ensureCapacityInternal方法来判断容器中是否有空位来容纳新近来的元素,ensureCapacityInternal源码如下:

private void ensureCapacityInternal(int minCapacity) {
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
        }

        ensureExplicitCapacity(minCapacity);
    }

  这里需要注意的一点是,在ArrayList中其实对于不传参数的构造函数和传长度参数为0是不一样的,对于不传参数的构造函数来说,对于容器数组引用是选择指向DEFAULTCAPACITY_EMPTY_ELEMENTDATA,然后就可以理解上面这里对于ensureCapacityInternal中的if中判断的含义,就是无参构造中说明的此时判断条件成立说明调用的是无参构造则第一次插入数据此时传入ensureCapacityInternal这里的minCapacity=1,从而进入if循环后重新赋值为10(DEFAULT_CAPACITY),在第一次调用add方法的时候就默认扩充容器大小为长度为10的数组。
  当然,这里我们进一步来继续考虑ensureExplicitCapacity函数的作用,源码如下:

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

        // overflow-conscious code
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }

  很显然,它的作用之一就是给modCount变量进行加1操作,这个变量的作用可以理解为ArrayList的版本,每次进行一次增删改时都会改变ArrayList的内容,所以会改变modCount的值从而得到一个新版本的数组,之所以这么记录的一个原因就是防止iterator得到的迭代器在数组进行修改的时候迭代到了修改以前的老版本的数据得到错误的数据,所以通过modCount记录的版本号,在每次迭代器获取数据之前,会先判断modCount版本号是否和最新版本号一致(获取迭代器的时候,会一并获取到当前的modCount记录版本号),如果不一致会抛出异常提示迭代器对应的容器中数据在迭代过程中被修改,这个原理是所有容器获取迭代器的时候都会用到的。
  继续回到ensureExplicitCapacity函数的作用,这里显然就是判断是否需要扩容,如果判断新插入数据所需数组大小超过了现在容器大小,则会调用grow函数进行扩容,那么继续看grow函数的扩容源码:

private void grow(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);
      // 出现一下情况只有少数几种情况,第一就是初始化长度时故意找茬设置的初始长度为0或者1,
      // 从而导致newCapacity和oldCapacity一样没边;另外一种就是在addAll中一次性插入多条数据时一次
      // 性要求的坑位过大,导致扩容得到的新数组长度还是无法完全容纳满新插入的全部数据,
      // 只有这些情况下才会进入下面逻辑
        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的扩容逻辑实现,就是先获取老的容器的数组长度,注意入参是要求的容器最低需要满足的数组长度,然后新容器长度尝试设置为newCapacity = oldCapacity + (oldCapacity >> 1),这里通常大家喜欢说是设置为原有数组的1.5倍长度是不对的,因为这里只是右移一位,假设构造函数调用的是无参构造,然后进行了两次扩容,那么数组长度变化应该是:

初始数组长度10
第一次扩容:newCapacity = 10 + 10 >> 1 = 15  这一步确实是容量扩充1.5倍
第二次扩容:newCapacity = 15 + 15 >> 1 = 15 + 7 = 22 很显然这里就不是所谓的扩容1.5倍了

  所以对于大家有的网上的博客说的ArrayList扩容每次是扩容原来数组的1.5倍的说法严格来说是错误的。
  继续分析以上grow函数,发现在获取了新数组长度后,会调用Arrays.copyOf(elementData, newCapacity)来进行最后的扩容,其内部实现是:

public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
        @SuppressWarnings("unchecked")
        T[] copy = ((Object)newType == (Object)Object[].class)
            ? (T[]) new Object[newLength]
            : (T[]) Array.newInstance(newType.getComponentType(), newLength);
        System.arraycopy(original, 0, copy, 0,
                         Math.min(original.length, newLength));
        return copy;
    }

  所以可以看到扩容的真实情况是声明一个新的长度为newCapacity的数组,然后将原来的数组中所有数据都拷贝到新数组中,可见这个过程其实是耗时很长的,所以如果我们的应用需要频繁往某个ArrayList中插入数据时,最好提取大致评估一下数组中会有多少数据,然后给一个初始长度进行初始化,这样可以有效减少多次扩容造成的不必要的性能消耗。

    1. add(int index,E element)
      在明白了add(E e)方法逻辑后,再看这个方法的实现,就会十分的简单,源码如下:
public void add(int index, E element) {
        rangeCheckForAdd(index);

        ensureCapacityInternal(size + 1);  // Increments modCount!!
        System.arraycopy(elementData, index, elementData, index + 1,
                         size - index);
        elementData[index] = element;
        size++;
    }

  这个函数的作用就是在指定坑位插入数据,那么很显然第一步就是要检验index给的值是否合法,这里检验是否合法需要注意的是判断的条件是:index > size || index < 0,即插入元素的位置必须是在已有元素中间插入,不能数组中现在只有3个元素,然后直接往第五个位置插入数据而不管第四个位置,然后这一系列都OK以后,进行数据插入,插入方式是把index位置后的所有元素都依次往后挪一位。

    1. addAll(Collection<? extends E> c) 和 addAll(int index, Collection<? extends E> c)
      这两个也都十分好理解,就是在单个插入的基础上变成一次插入一个批次的集合数据,判断逻辑和插入逻辑在看完以上add基础上十分容易理解,再次不再赘述。
      介绍完了主要的新增数据相关方法后,接下来就要看获取数据的方式。

get

  由于容器实现是基于数组,所以对于获取数据的方法就显得十分简单了,源码如下:

public E get(int index) {
        rangeCheck(index);

        return elementData(index);
    }

  只是做了简单的参数是否合法的校验,校验通过就直接取数组中数据即可。介绍了增和查的方法,接下来介绍一下改的方法,即set。

set

  和获取的方法类似,依托于数组的性质,改数据的逻辑也十分的简单,代码如下:

public E set(int index, E element) {
        rangeCheck(index);

        E oldValue = elementData(index);
        elementData[index] = element;
        return oldValue;
    }

  代码先校验位置参数,然后存储需要修改的值的旧的值,然后替换为新的值后返回旧的数据。
继续介绍增删改查中最后的删除方法,即remove方法的实现。

remove

  remove实现主要有两种,分别是删除指定位置的元素和删除不知道位置的指定元素。在理解了add逻辑后,删除其实也是洒洒水啦,下面对这两种进行介绍。

    1. remove(int index)
      这个方法就是删除index位置的元素,这个的逻辑和往指定位置插入数据的逻辑属于一个逆向逻辑,实现方式极其相似,源码如下:
public E remove(int index) {
        rangeCheck(index);

        modCount++;
        E oldValue = elementData(index);

        int numMoved = size - index - 1;
        if (numMoved > 0)
            System.arraycopy(elementData, index+1, elementData, index,
                             numMoved);
        elementData[--size] = null; // clear to let GC do its work

        return oldValue;
    }

  先检查index合法性,然后修改modCount的值,保存需要删除的元素的值,然后将删除位置后所有的元素往前挪一位。

    1. remove(Object o)

  这个方法意思就是,我不知道o这个元素在哪个位置,但是我就是看它不爽,你们必须给我找到它藏在数组中的位置,然后删除它!那么由于不知道元素位置,所以要找到该元素,必须得遍历数组进行匹配。当然了,有可能数组中会存在多个匹配的元素,对于这个方法而言,只是删除从头到尾遍历出现的第一个匹配的对象,源码如下:

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;
    }

  可以看到,这里可以支持删除对象为null对象,所以也可以变形说明在进行add插入数据的时候,也是可以插入null对象的,回头看add的插入逻辑确实没有判断和限制插入的对象是否为null。这里逻辑很清晰,我们主要看一下其中调用的fastRemove方法有啥秘密,源码如下:

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; // clear to let GC do its work
    }

  这里看fastRemove名称就说明会比之前介绍的remove方法要快一些,看代码才知道它快只是快在省去了index的校验,因为这个是内部调用的方法,调用之前确实是知道index肯定是合法的,所以没必要进行校验,同时它也不会保存被删除的数据和返还数据,只是省去了这两步,所以叫fastRemove咯。
  以上就是主要用到一些方法,至于还有一些例如containsindex等方法,在理解了本文介绍的这几个方法后,这些方法的逻辑都是大同小异的,都是通过遍历之类的来操作数组获取结果,不再进行重复的赘述,下期再见其它容器源码介绍。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值