Java入门系列之集合ArrayList源码分析

前言

上一节我们通过排队类实现了类似ArrayList基本功能,当然还有很多欠缺考虑,只是为了我们学习集合而准备来着,本节我们来看看ArrayList源码中对于常用操作方法是如何进行的,请往下看。

ArrayList源码分析

上一节内容(传送门《https://www.cnblogs.com/CreateMyself/p/11440876.html》)我们在控制台实例化如下一个ArrayList,并添加一条数据,如下

  ArrayList<Integer> list = new ArrayList<>();
  list.add(1);

初始化容量分析

首先实例化了ArrayList集合,上一节我们写了一个排队类的基本操作,最终我们通过优化,将数组容量放在构造函数中进行,若未给定数组容量则默认给定一个容量,接下来我们来看看源码中初始化了一个集合到底提前做了哪些准备工作呢?

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() {
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
.....

在我们初始化集合且类型为基本数据类型时,会有如上两个函数,一个是默认的构造函数,一个是带参数的构造函数。因为给出的例子并未包含参数,所以则是走下面一个构造函数,我们再来看看ArrayList中定义的变量,如下:

    //默认初始化容量
    private static final int DEFAULT_CAPACITY = 10;

    //数组空实例
    private static final Object[] EMPTY_ELEMENTDATA = {};

    //默认空数组实例
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

    //被操作数组
    transient Object[] elementData; 

    //数组大小
    private int size;

    //数组最大容量
    private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

如上我们未给定容量时,则初始化一个空数组实例,若我们给定了容量,则走如上第一个构造函数,如果容量大于0则数组容量则为我们给定的容量,如果等于0则为空数组实例,否则抛出容量非法。接下来到了第二步,当我们添加元素2时,看看添加方法是如何操作的。

添加元素分析

//添加元素实现
public boolean add(E e) {
        ensureCapacityInternal(size + 1);
        elementData[size++] = e;
        return true;
}

我们继续看看ensureCapacityInternal(size + 1)方法,此方法用来计算数组容量,看看最终方法实现,如下:

private void ensureCapacityInternal(int minCapacity) {
        ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
    //计算容量
    private static int calculateCapacity(Object[] elementData, int minCapacity) {
        //当实例化集合时未给定数组容量或者指定容量为0时,则此时数组为空数组实例
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            //此时minCapacity为1,通过Math.max函数将minCapacity和DEFAULT_CAPACITY(默认容量)比较返回【10】
            return Math.max(DEFAULT_CAPACITY, minCapacity);
        }
        //当实例化时给定数组容量大于0,则直接返回添加一个元素后的容量即(size+1)
        return minCapacity;
    }
    //判断是否扩容
    private void ensureExplicitCapacity(int minCapacity) {
        modCount++;

        // 若计算过后的数组容量大于数组存储长度时则扩容
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }
//扩容核心实现
private void grow(int minCapacity) {

        //被操作数组实际容量
        int oldCapacity = elementData.length;
        
        //新容量 = (实际容量 + 实际容量/2并去模)即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);
}


//计算数组最大容量
private static int hugeCapacity(int minCapacity) {
        if (minCapacity < 0) // overflow
            throw new OutOfMemoryError();
        
        //若数组大小大于定义的最大数组大小则新容量最大为整数最大值,否则为定义的最大数组大小        
        return (minCapacity > MAX_ARRAY_SIZE) ?
            Integer.MAX_VALUE :
            MAX_ARRAY_SIZE;
}

如上红色标记的是自动扩容定义的数组最大容量,这里需要解释下oldCapacity >> 1是啥意思,学校所学都还给了老师,查了资料才搞懂,这里也做个备忘录。>>在计算机表示右移,大部分情况下我们使用这种运算符比较少,但是这里为何不直接乘除呢?而且我们还看的懂些,使用左右移,运算速度快,直接乘除需要cpu计算消耗内存。刚一开始看到这个我是懵逼的,其实很简单。比如32,我们有2进制表示则为100000,怎么计算来的呢,如下:

(1 * 2 ^ 5)+(0 * 2 ^ 4)+(0 * 2 ^ 3)+(0 * 2 ^ 2)+(0 * 2 ^ 1)+(0 * 2 ^ 0)= 32 + 0 + 0 + 0 + 0 + 0 = 32。

 

好了我们知道32表示为二进制则是【100000】,那么32>>1则表示将十进制32转换为二进制后整体向右移动一位,将左边空余的补0,右边多余的剔除,如果是左移则相反(这里需注意int为32位,但是数字没那么大,所以左侧肯定全部为0,这里我们省略了哦),如下:

所以32>>1向右移动一位后如图,那么计算结果和上述第一张图一样,如下:

(0 * 2 ^ 5)+(1 * 2 ^ 4)+(0 * 2 ^ 3)+(0 * 2 ^ 2)+(0 * 2 ^ 1)+(0 * 2 ^ 0)= 0 + 16 + 0 + 0 + 0 + 0 = 16。

为了验证上述结果,我们通过代码来打印看看是否正确,如下:

 System.out.println( 32 >> 1);

通过如上图我们很容易得出结论:如果是右移即>>,那么用原数据除以2的位数次幂并舍去模,如果是左移即<<,那么用原数据乘以2的位数次幂。比如11>>2,通过11除以2^2,立马得出结果为2。若是11<<2,则是11*2^2,结果将是44。分析源码到这里为止,我们可得出如下结论:

若未给定初始化容量,则默认初始化容量为10且初始化默认容量的时机是在进行添加操作时。

自动扩容大小为1.5倍原始容量。

容量最大为Integer.MAX_VALUE即2147483647。

添加指定索引元素分析

上述我们只是分析完了初始化集合实例以及添加元素,接下来我们在指定索引位置添加元素看看,如下:

public static void main(String[] args) {

        ArrayList<Integer> list = new ArrayList<>();
        list.add(1);
        list.add(2);

        //添加元素2到索引5
        list.add(5,2);
}

依据上述我们所分析,因为在初始化集合时我们并未指定容量,所以当我们添加元素时,此时集合的容量默认为10,接下来我们在索引为5的位置添加元素2,那么是不是就可以呢?

我们以为默认容量为10,在指定索引为5插入元素不会有问题,但是结果却是抛出了异常,这说明不是以数组默认容量或提供的初始容量来作为判断依据,而是以数组实际大小来进行判断,为了证明我们的观点,我们来分析在指定索引位置插入元素的方法,如下:

//添加指定索引元素
public void add(int index, E element) {

        //检查索引范围,确认是否添加
        rangeCheckForAdd(index);

        ensureCapacityInternal(size + 1);
        System.arraycopy(elementData, index, elementData, index + 1,
                         size - index);
        elementData[index] = element;
        size++;
}
private void rangeCheckForAdd(int index) {

        //要添加的元素索引不能大于数组实际大小或小于0,否则抛出异常
        if (index > size || index < 0)
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}

有的人可能就问了,分析源码有什么意义或作用吗?作用太多了,一是了解背后本质原理不会出现自认为所谓的“坑”,二是通过学习并写出高质量的代码,三其他等等。我们有了对原理的了解,接下来我们就来做一个题目,如下:

public static void main(String[] args) {
        ArrayList<Integer> list = new ArrayList<>();
        list.add(1);
        list.add(2);
        list.add(3);
        list.add(4);
        list.add(5);
        list.add(6);
        list.add(7);
        list.add(8);
        list.add(9);
        list.add(10);
        list.add(6,2);
}

因为我们知道默认初始化容量为10,所以当添加元素到11时即上述在索引6的位置插入元素2,此时将自动扩容且容量大小为15(如果还是不懂,建议再重头复习下本篇文章)。接下来我们再来分析分析trimToSize方法。

trimToSize分析

首先我们来看如下一段代码:

public static void main(String[] args) {
        ArrayList<Integer> list = new ArrayList<>(20);
        list.add(1);
        list.add(2);

        list.add(6,2);
        list.trimToSize();
}

如上我们提供初始化容量为20,但是呢结果我们实际仅仅只添加了三个元素,在数组中剩余17个元素却占着坑,所以这个时候为了解决这样的问题就引入了trimToSize方法,旨在解决如下三个问题

将集合缩减到当前集合实际存储大小

最小化集合实例的存储

当我们需要缩减集合并最小化存储时

public void trimToSize() {
        modCount++;
        //若数组实际大小小于数组容量时
        if (size < elementData.length) {
            //若数组实际大小为0时则数组为空实例,否则复制数组到当前数组大小
            elementData = (size == 0)
              ? EMPTY_ELEMENTDATA
              : Arrays.copyOf(elementData, size);
        }
}

remove分析

在java中可以针对指定元素所在索引位置删除,也可以直接删除元素,下面我们首先来看看根据索引删除元素,如下:

//删除指定索引元素并返回删除元素值
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; 

        return oldValue;
}

若我们要直接元素,比如删除上述添加的元素2,此时针对删除方法尤其重载,其参数是对象,所以我们需要将元素2转换为包装类,比如如下:

//删除指定元素
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;
}
    
    
//快速删除(本质上采用复制的方式)    
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;
}

其实我们看到源码中很多操作方法内部都是采用复制的方法来进行,比如删除、添加集合等等,同时我们注意到在涉及到复制时都会存在比如上述设置为空的情况,下面我们来稍微研究下这么做的意义在哪里?

public static void main(String[] args) {
        ArrayList<Integer> list = new ArrayList<>();
        list.add(1);
        list.add(2);
        list.add(3);
        list.add(4);
        list.add(5);
        list.add(6);

        Integer[] array = list.toArray(new Integer[0]);

        System.arraycopy(array, 3, array, 2,
                3);

        for (int i = 0; i < array.length; i++) {
            System.out.println(array[i]);
        }
}

如上我们通过调用系统提供的复制方法模拟删除,我们删除数组中为3的元素,然后打印数组中元素,如下:

根据调用复制来看,复制的起始位置为索引3,然后将数组中元素4、5、6进行复制,但是将原有数组中的元素3、4、5进行了覆盖,但是此时元素6没有元素覆盖,所以数组中依然有6个元素,所以为了GC,我们需要将元素6设置为空,并且长度设置为5,这样才是最优代码,同样也就达到了在删除元素时elementData[--size] = null同等效果。

总结 

本节我们详细分析了ArrayList源码,ArrayList的本质上是通过动态扩容一维数组来实现,同时介绍了比较常用的几个方法,当然还有比如java 8中出现的通过lambda表达式进行遍历没有再详细去一一解释,后续在学习或做项目时用到了发现有需要补充的地方,我会回过头来再进行研究,暂且到这里为止,下节我们继续学习其他集合并分析源码,感谢您的阅读,下节见。

posted @ 2019-09-03 00:13  Jeffcky  阅读(374)  评论(2编辑  收藏  举报