从零开始的源码分析(ArrayList篇)

前言

感觉Java集合是面试中必定会被问到的内容之一,所以这一次打算先把面试中必定会问的几个问题的源码搞明白。
这系列文章主要参考大佬博客:Java 集合源码解析

从迭代器开始说起

迭代器其实使我们在平时使用的时候用的比较“少”的一个东西,一般当需要进行遍历的时候我们会写如下代码:

for(int i=0;i<collections.size();i++)

但是当有时候操作和下标没有关系的时候,我们经常会使用foreach,这样看起来更加简练:

for(Int num:array){}

其实在java中foreach是一种语法糖,当前端编译解糖的时候,会把他还原成如下代码:

Iterator iterator = array.iterator();
while(iterator.hasNext()){
...//操作
iterator.next();
}

这里的Iterator指的就是迭代器。

以ArrayList的Iterator为例

iterator本质上是一个接口,不同的集合对其都有不同的实现,在接口中Iterator有以下方法:

  • hasNext()
  • next()
  • remove()
  • forEachRemaining() (1.8之后添加)

我们来看一下ArrayList中的实现。

变量

迭代器中维护了一个cursor指针和lastRet,从旁边的注释上我们可以看出一个是下一个数的下标,一个是前一个数的下标。
这句话的意思是说,其实迭代器是位于数组中的两个元素之间的间隙,而非指向某一个元素,一开始的时候,迭代器位于0和-1之间。

int cursor;       // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;

hasNext()

接下来我们再来看hasNext()方法。
hasNext()直接返回了cursor != size,其中的size指的ArrayList的变量,表示当前ArrayList包含元素的个数。
所以也非常好理解,就是判断一下迭代器后面的那个数时候小于元素个数。

public boolean hasNext() {
    return cursor != size;
}

next()

接下来再看next()方法。
第一行的checkForComodification()方法是查看数组的元素个数是否发生了变化。可以看到之后的所有方法中,都会调用这个子方法,所以说,在使用迭代器的时候任何改变数组元素的操作都会使得迭代器抛出异常。
checkForComodification()比较的是modCount != expectedModCount,其中的modCount指的是变化的次数。
取出数组中的有效元素后,执行了cursor = i + 1,这里就把后面的元素下标置为i+1,最后返回i处元素的同时,把lastRet置为i
所以说,迭代器在数组的间隙其实也只是一个概念上的说法,其实上是两个记录前面元素下标和后面元素下标的整型变量。这也是为什么迭代器的迭代是Iterator.next()而非iterator = Iterator.next()

@SuppressWarnings("unchecked")
public E next() {
    checkForComodification();
    int i = cursor;
    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];
}

remove

最后是remove()方法,这个方法其实有一些奇怪,首先前面很好理解,删除元素,然后把cursor向前移动。但是lastRet = -1就显得非常怪异。
可以看出在remove中是不检查数组是否发生了变化的,那么我们如果连续两次调用remove方法呢?
第二次的时候就会发生lastRet = -1 < 0从而抛出异常,所以我们可以得出结论,在一个迭代器中只能进行一次remove操作。
同样的,如果迭代器还没有开始迭代,这个时候lastRet = -1也会抛出同样的错误。

public void remove() {
    if (lastRet < 0)
        throw new IllegalStateException();
    checkForComodification();

    try {
        ArrayList.this.remove(lastRet);
        cursor = lastRet;
        lastRet = -1;
        expectedModCount = modCount;
    } catch (IndexOutOfBoundsException ex) {
        throw new ConcurrentModificationException();
    }
}

forEachRemaining

这是一个1.8新加的函数,可以让我们在迭代到某个位置之后对所有的剩余元素进行操作,传入的参数是操作的具体方法。

 @Override
@SuppressWarnings("unchecked")
public void forEachRemaining(Consumer<? super E> consumer) {
    Objects.requireNonNull(consumer);
    final int size = ArrayList.this.size;
    int i = cursor;
    if (i >= size) {
        return;
    }
    final Object[] elementData = ArrayList.this.elementData;
    if (i >= elementData.length) {
        throw new ConcurrentModificationException();
    }
    while (i != size && modCount == expectedModCount) {
        consumer.accept((E) elementData[i++]);
    }
    // update once at end of iteration to reduce heap write traffic
    cursor = i;
    lastRet = i - 1;
    checkForComodification();
}

更强大的ListIterator

还是以ArrayList为例,ListIterator继承了Itr,而Iterator又实现了Iterator接口,总之我们可以把ListIterator看做是包含有Iterator功能的拓展。
ListIterator相除了能够获得下一个元素,还能够获得上一个元素,不过原理和前面的next方法类似,这里就不再展开了。我们看一下set()add()方法。
操作与remove类似,都是先进行检查,然后调用自身的set()add()方法。区别在于一个是在前元素的指针后面插入,也就是代替原来的元素,一个是在后一个元素的指针出进行插入。
但是在add方法中,在操作完成之后会把lastRet设置为-1,这也就意味着不能连续操作 ,而set则不受限制,这应该得益于set不会改变元素的个数。
事实证明,连续的add操作是可以的,这是因为add操作中执行了expectedModCount = modCount确保了操作后元素变化相等。
并且没有对lastRet的值进行判断,所以虽然是-1但是依旧能够连续add,但是调用和lastRet相关的方法就会报错。感谢杰哥指出。

public void set(E e) {
    if (lastRet < 0)
        throw new IllegalStateException();
    checkForComodification();

    try {
        ArrayList.this.set(lastRet, e);
    } catch (IndexOutOfBoundsException ex) {
        throw new ConcurrentModificationException();
    }
}

public void add(E e) {
    checkForComodification();

    try {
        int i = cursor;
        ArrayList.this.add(i, e);
        cursor = i + 1;
        lastRet = -1;
        expectedModCount = modCount;
    } catch (IndexOutOfBoundsException ex) {
        throw new ConcurrentModificationException();
    }
}

关于迭代器的一些问题

昨天和杰哥进行测试,发现迭代器调用remove或者add方法之后,可以继续调用next的方法。
并且调用next的方法之后,由于lastRet值被next方法更新为了前一个元素的下标,所以又可以调用setremove方法,所以迭代器虽然不能在同一个位置反复删除、插入后删除、插入后替代,但是可以一边遍历一边删除(添加)。
但是如果一边迭代一边使用list接口的add方法,会由于checkForComodification()方法导致异常。

来自阿里的面试题:Iterator和listIterator有什么区别?

IteratorlistIterator都是接口,在不同的类中有不同的实现方式,listIterator继承了Iterator,所以功能比Iterator更多。
Iterator中只有4个方法:next()hasNext()removeforEachRemaining
listIterator增加了addsethasPreviousprevious()方法。

回到集合

Java中的集合主要按照两种接口划分:CollectionMap
我们常用的ArrayListLinkedList都实现了List接口,而List接口又实现了Collection接口,所以从Collection接口中我们可以窥探到一些集合所拥有的方法。
Collection接口一个包含有15个方法大多是常用的add之类的操作,所以这边也就不展开了。
这里说明一下toArray()方法,我们还是以ArrayList中的实现为例,可以看到在传入一个数组之后,如果数组的长度小于集合的长度,会使用Arrays类下的copyOf方法,这个时候返回的数组不再试传入的那个数组了,并且它的长度也不是原来的长度,而是变成了集合的长度。
如果等于大于集合长度,则返回的是依旧是原来的数组,只不过把值复制了一份。
如果不传入数组的话,则直接返回一个数组。

public <T> T[] toArray(T[] a) {
   if (a.length < size)
       // Make a new array of a's runtime type, but my contents:
       return (T[]) Arrays.copyOf(elementData, size, a.getClass());
   System.arraycopy(elementData, 0, a, 0, size);
   if (a.length > size)
       a[size] = null;
   return a;
}
public Object[] toArray() {
        return Arrays.copyOf(elementData, size);
        }

但是以上的数组都是对象数组,也就是说不能返回int[]类型的数组(但是可以返回String[]类型的)。
那如果想要返回int[]呢?
可以考虑使用1.8新增的聚合操作:

int[] intArr = list.stream().mapToInt(Integer::intValue).toArray();

List接口

List接口继承了Collection接口,除了原来的方法外还提供了其他一些方法,比如查询元素所在的位置indexOflastIndexOf
List接口中的addAll()方法需要特别注意一下,这个方法可以在特定位置插入指定集合的所有元素,这也导致了ArrayList在扩容的时候如果扩容2倍之后仍然不能装下所有的元素,则扩容到最小容量。

subList

List接口中,subList一个比较特殊的方法(其实subString也是一样)。
我们来看一下源码,还是以ArrayList为例:

public List<E> subList(int fromIndex, int toIndex) {
        subListRangeCheck(fromIndex, toIndex, size);
        return new SubList(this, 0, fromIndex, toIndex);
    }

可以看到返回的并不是一个ArrayList而是SubList那么这个SubList是什么呢?

private class SubList extends AbstractList<E> implements RandomAccess

SubListArrayList一样继承了AbstractList,而RandomAccess是个标志性接口,表明这个集合是可以随机访问的。
然后再看一下SubList的构造函数,SubList不同于ArrayList,它没有自己的元素,而是声明了一个成员变量parent,我们可以看到这个变量是由final修饰的,所以引用地址不可修改(但是依然可以改变引用对象中的成员),而这个parent指向了前面传进的外部类this,然后保存了起始地址和终止地址,以及改变次数,后面的操作也是通过parent进行操作,这里就不贴出来了。
由此我们可以得出,subList返回的并不是一个新的集合,而是一个对原集合的引用,修改原集合的数据会影响到SubList,反过来亦如是。
这就导致了有时候我们看起来只保存了一个很小的子集合,实际上这个集合可能很大。

private class SubList extends AbstractList<E> implements RandomAccess {
    private final AbstractList<E> parent;
    private final int parentOffset;
    private final int offset;
    int size;
    SubList(AbstractList<E> parent,
            int offset, int fromIndex, int toIndex) {
        this.parent = parent;
        this.parentOffset = fromIndex;
        this.offset = offset + fromIndex;
        this.size = toIndex - fromIndex;
        this.modCount = ArrayList.this.modCount;
    }
    ...//省略了其他方法
}

ArrayList

千呼万唤始出来,终于轮到了ArrayList,当然在这之前我们还要稍微提一下AbstractCollectionAbstractList

AbstractCollection是对Collection接口的一个抽象类实现,包含了一般集合的常用方法实现,但是不包括添加单个元素add(E)

AbstractList抽象类继承了AbstractCollection并且实现了List接口,说明这是一个List集合。同样的AbstractSet继承了AbstractCollection且实现了Set接口。AbstractList实现了随机访问的接口,但是不实现添加和替换的方法。

成员变量

我们先来看一下ArrayList的成员变量,从类后面接的一大串接口我们就可以知道这个类不但实现了随机访问,还实现了Clone和序列化,成员变量有一些多,这里为了显示简洁一些我把注释先删去了,接下来逐个介绍。

  • serialVersionUID:用于验证序列化一致的ID
  • DEFAULT_CAPACITY:默认容量,10
  • EMPTY_ELEMENTDATA和DEFAULTCAPACITY_EMPTY_ELEMENTDATA默认的空数组,一会说他们的区别。
  • elementData:数组,并非实际的元素个数。
  • size:实际元素的个数。
  • MAX_ARRAY_SIZE:最大容量
public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
    private static final long serialVersionUID = 8683452581122892189L;
    private static final int DEFAULT_CAPACITY = 10;
    private static final Object[] EMPTY_ELEMENTDATA = {};
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
    transient Object[] elementData; // non-private to simplify nested class access
    private int size;
    ...
    private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
    ...
}

无参构造函数和有参构造函数

构造函数其实没有太多可以说的地方,如果指定了大小或者集合元素则初始化一个Object数组,如果没有或者大小为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);
    }
}
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;
    }
}
public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

我们可以看到,有参构造函数使用的空数组是EMPTY_ELEMENTDATA,而无参使用的是DEFAULTCAPACITY_EMPTY_ELEMENTDATA
那么这两者之间有什么区别呢?
我们来看注释:

/**
* DEFAULTCAPACITY_EMPTY_ELEMENTDATA
* 用于默认大小的空实例的共享空数组实例。
* 我们将其与空的元素数据区分开来,以知道在添加第一个元素时要膨胀多少
**/

/**
* EMPTY_ELEMENTDATA
*存储ArrayList元素的数组缓冲区。
* ArrayList的容量是此数组缓冲区的长度。
* 添加第一个元素时,
* 任何为空并elementData==DEFAULTCAPACITY_EMPTY_ELEMENTDA的ArrayList
* 都将扩展为DEFAULT_CAPACITY。
*/

看起来是说如果是DEFAULTCAPACITY_EMPTY_ELEMENTDA则在添加第一个元素的时候扩容为默认大小10。
我们来看一下calculateCapacity方法,这个方法用于在增加数据的时候确定容量大小。可以看到如果数组是DEFAULTCAPACITY_EMPTY_ELEMENTDATA的话,则会返回10和最小容量的最大值,不是的话直接返回最小容量。
也就是说,如果我们采用的无参构造函数,那么当我们添加一个元素之后,数组的实际长度是10,而采用有参构造函数的话,实际长度是1。

private static int calculateCapacity(Object[] elementData, int minCapacity) {
	if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
	    return Math.max(DEFAULT_CAPACITY, minCapacity);
	}
	return minCapacity;
}

增加和删除

我这边节选了部分增加和删除函数,可以看到无论是增加还是删除,ArrayList中都需要进行移动元素,而移动元素依靠的是直接复制元素到对应的下标。

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

扩容

ArrayList中,除了在添加元素的时候会判断容量需不需要扩容外,我们还可以使用ensureCapacity(int)方法来手动扩容,不过实际执行扩容的方法还是grow
可以看到代码并不长。
默认是扩容到原来的1.5倍,然后检查是否溢出或者超过数组上限,如果超过数组上限的话就直接将其扩容为整型的最大值。
最后将元素的引用改变为一个新的数组。

private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    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);
}
private static int hugeCapacity(int minCapacity) {
    if (minCapacity < 0) // overflow
        throw new OutOfMemoryError();
    return (minCapacity > MAX_ARRAY_SIZE) ?
        Integer.MAX_VALUE :
        MAX_ARRAY_SIZE;
}

后记

本来想一次性把所有集合的源码全部写完的,但是后来发现光是ArrayList就写了这么长,所以决定还是分3篇写。
ArrayList的本质其实是一个数组,但是单单是一个数组就在java中写了几百上千行的代码,可见想写出一个没有BUG的模块需要花多大的功夫。
在源码中,数组的复制大多是采用Arrays.copyOf而非采用手动数组复制,这个在之后的编码中需要注意。同样的,如果是移动数组元素,可以采用
System.arraycopy(elementData, index+1, elementData, index, numMoved);其中参数的意思是把后面一个数组的indexnumMoved元素复制到前一个数组从下标index+1开始的位置。
还需要注意的一点是不需要的对象及时置空,以方便GC回收,这是因为GC时候会放过浮动垃圾,置为null可以有效防止内存溢出从而导致一次Full GC


在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_33241802/article/details/106943010
今日推荐