阅读源码,从ArrayList开始

前言

为啥要阅读源码?一句话,为了写出更好的程序。
一方面,只有了解了代码的执行过程,我们才能更好的使用别人提供的工具和框架,写出高效的程序。另一方面,一些经典的代码背后蕴藏的思想和技巧很值得学习,通过阅读源码,有助于提升自己的能力。当然,功利的讲,面试都喜欢问源码,阅读源码也有助于提升通过面试的概率。

结合今天的主题,一个很简单的问题,在刚学习集合时,我们都使用过如下代码,但是下面几行代码有区别吗?

List list1 = new ArrayList();
List list2 = new ArrayList(0);
List list4 = new ArrayList(10);

有人可能会说,没指定初始值就按默认值,指定了初始值就按指定的值构造一个数组。真的是这样吗?如果你对上面这个问题有疑惑,就说明你该看看源码了。

学习编程的过程千万不要人云亦云,一定要亲自看看。

如何阅读源码,每个人的方式不同,这里仅以自己习惯的方式来说。以今天的主题为例,ArrayList是干嘛的?怎么用?这就延伸到一条路线,先看类名及其继承体系——它是干嘛的,再看构造函数——如何造一个对象,当然,构造函数会用到一些变量,所以在此之前我们需要先了解下用到的常量值和变量值,最后,我们需要了解常用的方法以及它们是如何实现的。

对于阅读大多数类基本都是按照:类名——>变量——>构造函数——>常用方法。

本文只会选取有代表性的一些内容,不会讲到每一行代码。


类签名

好像没有类签名这个说法,这里是对照函数签名来说的,简单说就是一个类的类名以及它实现了哪些接口,继承了哪些类,以及一些泛型要求。

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable

从上述代码可以看出,ArrayList实现了:

Cloneable, Serializable接口,具有克隆(注意深度拷贝和浅拷贝的区别)和序列化的能力,

RandomAccess接口,具有随机访问的能力,这里说的随机主要是基于数组实现的根据数组索引获取值,后期结合LinkedList分析更容易理解。

List接口,表明它是一个有序列表(注意,此处的有序指的是存储时的顺序和取出时的顺序是一致的,不是说元素本身的排序),可以存储重复元素。

AbstractList已经实现了List接口,AbstractList中已经实现了一些常见的通用操作,这样在具体的实现类中通过继承大大减少重复代码,需要的时候也可以重写其中方法。


变量

    //序列化版本号
    private static final long serialVersionUID = 8683452581122892189L;

    //常量,默认容量为10
    private static final int DEFAULT_CAPACITY = 10;

    //常量,初始化一个空的Object类型数组
    private static final Object[] EMPTY_ELEMENTDATA = {};

    //常量,本质也是一个空的Object类型数组,与EMPTY_ELEMENTDATA用于区别初始化时指定容量0还是默认不指定
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

    //变量,真正用来存储元素的数组名
    transient Object[] elementData; 

    //数组中实际存储的元素数量,未初始化则默认为0
    private int size;

上述变量中的大部分值都比较好理解,令人疑惑的事EMPTY_ELEMENTDATA和DEFAULTCAPACITY_EMPTY_ELEMENTDATA,除了变量名,其他都一样,好在注释和后续的方法为我们说明了,简单说,就是针对初始化时,不同的构造函数选用不同的变量名,即

List list1 = new ArrayList(); //此时用DEFAULTCAPACITY_EMPTY_ELEMENTDATA
List list2 = new ArrayList(0); //此时用EMPTY_ELEMENTDATA

为啥搞这么麻烦,是大神们闲得慌吗?显然不是,不信?请继续往下看。


构造方法


//不指定初始容量的构造函数
public ArrayList() {
    this.elementData = DEFAULTCAPACITY_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) {
        if (elementData.getClass() != Object[].class)
            elementData = Arrays.copyOf(elementData, size, Object[].class);
    } else {
        this.elementData = EMPTY_ELEMENTDATA;
    }
}

如上所示,ArrayList有三个构造函数:

不指定容量的情况下,此时直接构造一个空的数组,只有当添加第一个元素时,才会扩容为默认容量10。所以说并不是我们经常理解的直接构造一个容量为10的数组,到此时我们才理解为啥很多时候一些规范建议我们指定初始容量,因为这样可以减少一次扩容操作。注意,此时使用的是DEFAULTCAPACITY_EMPTY_ELEMENTDATA

指定容量时,小于0抛异常,大于0直接用指定的值构造一个数组,等于0时,也是构造一个空数组,但是此时使用的是EMPTY_ELEMENTDATA

有啥区别呢?关键在与扩容时的操作。继续往下看。

记住,ArrayList的扩容操作只可能发生在添加元素时。


常用方法

ArrayList的常用方法非常多,这里先排除一大批私有方法和内部类,看一下外部方法(尴尬,差一点一张图截不下):

看起来很多,这里只选取几个常用的,其他的可以类比着看。

add(E e)

第一个最常用的方法,添加元素(add)

public boolean add(E e) {
    //检查数组容量是否充足,不够则扩容
    ensureCapacityInternal(size + 1);  
    //注意,下方代码相当于elementData[size] = e; size++;
    elementData[size++] = e;
    return true;
}

可以看出,在添加元素时,第一步先检查数组容量是否充足,不够的话进行扩容,add方法的关键在于检查容量

检查容量:ensureCapacityInternal(int minCapacity)

//检查容量是否足够,不够则扩容
private void ensureCapacityInternal(int minCapacity) {
    ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}

//比较实际存储元素+1与数组的容量
private static int calculateCapacity(Object[] elementData, int minCapacity) {
	//若构造时不指定容量,则返回默认容量10或者现有实际元素+1中的最大值
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        return Math.max(DEFAULT_CAPACITY, minCapacity);
    }
	//构造时指定了容量,不管是0还是大于0,都返回实际容量+1
    return minCapacity;
}

//如果实际容量+1超过了现有容量(数组装不下了),则扩容
private void ensureExplicitCapacity(int minCapacity) {
    //记录修改次数,主要是为了遍历元素时发生修改则快速失败,此处不谈。
    modCount++;

    // 如果现有元素+1大于数组实际长度,则进行扩容
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}

关键来了,如何扩容

扩容方法:grow(int minCapacity)

private void grow(int minCapacity) {
    // 旧容量为数组长度
    int oldCapacity = elementData.length;
    //新容量为旧容量的1.5倍
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    //新容量小于实际元素+1,则按实际元素+1扩容
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    //新容量大于数组最大长度,根据实际选择容量为Integer.MAX_VALUE或者MAX_ARRAY_SIZE;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // 将旧数组元素复制到新数组
    elementData = Arrays.copyOf(elementData, newCapacity);
}

上述代码有一个关键方法Arrays.copyOf(elementData, newCapacity)用来复制集合中的元素,此处不再深入。


回到开始的问题

在创建ArrayList时,

不指定初始容量,即

List list1 = new ArrayList();
//此时,构造一个空的数组,第一次添加元素时,将数组扩容为10,并添加元素。

指定初始容量为0,即

List list2 = new ArrayList(0);
//此时,也构造一个空数组,但变量名和上面不一样。第一次添加元素时,将数组扩容为1,并添加元素。

指定初始容量为10,即

List list4 = new ArrayList(10);
//直接构造一个容量为10的数组,第一次添加元素时,不扩容。

所以说,如果我们大概确定将要使用的元素数量,应当在构造函数中指明,这样可以减少扩容次数,一定程度上提升效率。


小结

到目前为止,只是简单写了下ArrayList的构造函数和add方法,大部分内容都还没有深入。想要把每一个方法都写到,其实很难,也没必要。

通过上面的内容,回顾自己阅读源码的过程,既要“不求甚解”,更要“观其大略”,对于一些核心的过程,我们需要仔细分析;但是对没有经验的新手来说,弄清楚每个细节很难,有些内容现阶段可能还没法理解,把握整体结构很重要,先搞清楚大概,再对每一个细节深入。如果一开始就对某一细节一直深入,很可能迷失其中自己都走不出来了。

看到这里,你问我是不是对ArrayList完全了解了,哈哈,显然没有。但是,写到这里的时候,我的理解又深刻了不少。

心里觉得大概懂了不一定是真的理解,只有抱着把内容写出来让别人看明白的心态,才有可能加深理解。不知,你看明白了没?

posted @ 2020-10-09 20:27  希夷小道  阅读(415)  评论(0编辑  收藏  举报