Java中的ArrayList

ArrayList是Java开发中最常使用的集合之一。剖析内部使用实现机制,在应用中能够避免一些使用问题,更能得心应手。


扩容机制

首先来看ArrayListadd方法。

java
jdk/src/share/classes/java/util/ArrayList.java
public boolean add(E e) {
    // 确保内部容量足够,这里传递的最小容量是当前大小加1
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    // 添加元素
    elementData[size++] = e;
    return true;
}
public void add(int index, E element) {
    // 检查插入的下标是否满足要求,不能小于0和大于当前的size
    rangeCheckForAdd(index);

    // 确保容量足够
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    // 进行数组拷贝
    System.arraycopy(elementData, index, elementData, index + 1,
                     size - index);
    // 添加元素
    elementData[index] = element;
    // size自增
    size++;
}

两个add方法都调用了ensureCapacityInternal方法来确保添加元素前数组的容量是足够的。

java
jdk/src/share/classes/java/util/ArrayList.java
private void ensureCapacityInternal(int minCapacity) {
    // 如果调用的是无参构造器来初始话的,那么elementData就是EMPTY_ELEMENTDATA;如果是有参构造器,则不是。
    if (elementData == EMPTY_ELEMENTDATA) {
        // 确保首次扩容的大小不会小于默认的容量,默认容量大小为10。
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }

    // 保证容量需要达到minCapacity
    ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
    // 修改次数自增
    modCount++;

    // overflow-conscious code
    // 如果所需要的大小超过了当前已有的大小,
    if (minCapacity - elementData.length > 0)
        // 则进行扩容
        grow(minCapacity);
}

ensureCapacityInternal方法中判断了当前的ArrayList对象中的数组对象是否是EMPTY_ELEMENTDATA,如果是的话,则首次扩容直接扩容到默认容量。 然后在ensureExplicitCapacity方法中判断了所需容量是否超过了现有的容量,如果是则调用grow方法进行扩容。

这里解释一下什么是EMPTY_ELEMENTDATA?这是ArrayList中的一个空数组对象,用来标记是否是在创建ArrayList对象时没有传递初始容量。

java
jdk/src/share/classes/java/util/ArrayList.java
/**
 * Default initial capacity.
 */
// 默认初始容量
private static final int DEFAULT_CAPACITY = 10;

/**
 * Shared empty array instance used for empty instances.
 */
// 空数组对象
private static final Object[] EMPTY_ELEMENTDATA = {};

/**
 * 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.
 */
// 保存元素的数组
transient Object[] elementData; // non-private to simplify nested class access

/**
 * The size of the ArrayList (the number of elements it contains).
 *
 * @serial
 */
// 实际的元素的个数,注意和elementData.length进行区分,后者是大于等于size的
private int size;

看看构造方法的实现,可以发现如果是无参构造方法,没有指定初始容量的情况下,会将EMPTY_ELEMENTDATA这个空的标记数组赋值给this.elementData。这样在扩容操作的时候就知道需要直接扩容到初始容量。

java
jdk/src/share/classes/java/util/ArrayList.java
/**
 * Constructs an empty list with the specified initial capacity.
 *
 * @param  initialCapacity  the initial capacity of the list
 * @throws IllegalArgumentException if the specified initial capacity
 *         is negative
 */
public ArrayList(int initialCapacity) {
    super();
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal Capacity: "+
                                           initialCapacity);
    // 直接创建指定容量的数组
    this.elementData = new Object[initialCapacity];
}

/**
 * Constructs an empty list with an initial capacity of ten.
 */
public ArrayList() {
    super();
    // 将标记数组赋值给数组对象
    this.elementData = EMPTY_ELEMENTDATA;
}

/**
 * Constructs a list containing the elements of the specified
 * collection, in the order they are returned by the collection's
 * iterator.
 *
 * @param c the collection whose elements are to be placed into this list
 * @throws NullPointerException if the specified collection is null
 */
public ArrayList(Collection<? extends E> c) {
    elementData = c.toArray();
    size = elementData.length;
    // c.toArray might (incorrectly) not return Object[] (see 6260652)
    if (elementData.getClass() != Object[].class)
        // 拷贝数组
        elementData = Arrays.copyOf(elementData, size, Object[].class);
}

在实际使用的时候,如果事先知道数据的容量,最好使用有参构造方法来一次性初始化到目标容量。这样可以避免在中途进行扩容,扩容涉及内存拷贝,如果数据量大了,会很耗费时间,导致应用性能下降。

下面来看扩容机制最核心的实现:

java
jdk/src/share/classes/java/util/ArrayList.java
/**
 * The maximum size of array to allocate.
 * Some VMs reserve some header words in an array.
 * Attempts to allocate larger arrays may result in
 * OutOfMemoryError: Requested array size exceeds VM limit
 */
/*
 * 为什么这里是 Integer.MAX_VALUE - 8 ?
 * 上面的英文注释说明了是为了��数组对象的头留一些空间。
 */
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    // 新容量是旧容量的1.5倍
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    // 如果1.5倍就容量还是小于了所需大小,
    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();
    /*
     * 如果所需容量值大于了最大数组大小,则直接等于Interger的最大值;
     * 否则就是最大数组大小,因为此时1.5倍的旧容量已经大于了最大数组大小,这里直接扩容到最大数组大小,也无可厚非。
     */
    return (minCapacity > MAX_ARRAY_SIZE) ?
        Integer.MAX_VALUE :
        MAX_ARRAY_SIZE;
}

循环中删除元素的注意事项

for-each循环删除元素抛异常分析

为什么下面的代码会抛出异常?

java
jdk8-test/src/ant/lzip/net/collection/TestArrayList.java
public static <T> void testBadRemove(ArrayList<T> list) {
    for (T i : list) {
        // 问题1: 会抛出ConcurrentModificationException这个异常
        list.remove(i);
    }
}

可能直接看代码不知道for-each循环背后发生了什么,对class文件进行反编译后可以发现,调用了迭代器实现。

java
public static <T> void testBadRemove(ArrayList<T> list) {
	Iterator var1 = list.iterator();

	while(var1.hasNext()) {
		T i = var1.next();
		list.remove(i);
	}

}

那为什么调用迭代器就会报错呢?可以发现在循环中,调用的是迭代器的next方法,而remove方法调用的是ArrayList的方法,报错发生在调用next方法时。为什么这样会导致报错呢?这就要参考迭代器的实现了。

Itr内部类

ArrayList的迭代器是内部类实现Itr

java
jdk/src/share/classes/java/util/ArrayList.java
public Iterator<E> iterator() {
    // 创建ArrayList内部的迭代器实现
    return new Itr();
}

先来看看Itr类的属性:

java
jdk/src/share/classes/java/util/ArrayList.java
// 下一个需要返回的元素的下标
int cursor;       // index of next element to return
// 上次返回元素的下标
int lastRet = -1; // index of last element returned; -1 if no such
// 记录当前ArrayList的修改次数,目的是为了检测迭代过程中的并发修改。
int expectedModCount = modCount;

再看看导致报错的next方法。

java
jdk/src/share/classes/java/util/ArrayList.java
@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];
}

报错是在调用的checkForComodification这个方法中,看看里面做了什么。

java
jdk/src/share/classes/java/util/ArrayList.java
final void checkForComodification() {
    /*
     * 如果内部的修改次数不等于外部的修改次数,
     * 也就是说在迭代的过程中使用了当前迭代器外部的方法对ArrayList进行了修改,
     * 那么会破坏当前迭代器内的字段,比如cursor和lastRet,会导致当前迭代器的操作出现歧义,所以这里会直接并发修改异常。
     */
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

这个方法在检查迭代器内部的修改次数和外部ArrayList中的修改次数是否一致,否则抛出异常。那为什么上面导致了这两个变量不一致呢?这要回过头看看上面循环中调用的ArrayListremove方法。

java
jdk/src/share/classes/java/util/ArrayList.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);
    // 因为此时 size - 2 和 size -1 指向的是同一个对象,所以不需要保留多余的引用了;或者说 size - 1 就是待删除的元素。
    elementData[--size] = null; // clear to let GC do its work

    return oldValue;
}

可以发现,这里对modCount进行了自增,所以导致了和迭代器内部的状态不一致了,迭代器就会抛出异常。

其实上面的实现被称为fast-fail机制,尽管在物理上可能不会出现异常,但是在逻辑上是不正确的。所以上面的迭代器实现为直接抛出异常。 另外,并发修改异常ConcurrentModificationException并不一定就是指在多线程环境下发生的异常,上面的例子中可以看到,单线程环境中仍然可能发生这种异常。

正确循环删除元素

如果确实是需要在循环中删除元素,要么只使用迭代器的remove方法,要么只使用ArrayListremove方法,两个不能混用。

通过迭代器的remove方法来删除

java
jdk8-test/src/ant/lzip/net/collection/TestArrayList.java
public static void testGoodRemove(ArrayList<Integer> list) {
    /*
     * 如果是在是要在遍历中移除元素,要么通过迭代器的remove方法。
     * 要么只调用ArrayList的remove方法,而不要直接或间接地调用到迭代器的方法。
     */
    Iterator<Integer> itr = list.iterator();
    while (itr.hasNext()) {
        Integer next = itr.next();
        /*
         * 通过调用迭代器的remove方法,可以正常删除元素。
         * 问题2: 注意,如果不调用next方法,直接调用remove方法,仍然是会抛出IllegalStateException异常的。
         */
        itr.remove();
    }
}

现在带着两个问题来分析迭代器的remove方法:

  • 为什么通过迭代器的remove方法就不会报错?
  • 为什么在调用迭代器的remove方法之前要先调用next方法?
java
jdk/src/share/classes/java/util/ArrayList.java
public void remove() {
    /*
     * 如果没有调用过next方法,直接抛出异常。
     * 为什么这里要检查lastRet要小于0?
     * 因为该方法的设计目的就是删除上次访问过的元素,所以下面调用ArrayList的remove方法时传入的是lastRet。
     * 如果lastRet小于0,肯定是非法下标不能进行删除操作。
     */
    if (lastRet < 0)
        throw new IllegalStateException();
    // 检查并发修改
    checkForComodification();

    try {
        // 调用外部的ArrayList的remove方法
        ArrayList.this.remove(lastRet);
        // 因为删掉的元素原先的位置被它后面一个元素占据了,所以让
        cursor = lastRet;
        // 重置lastRet
        lastRet = -1;
        // 同步内外的两个修改次数变量,这样在并发修改检查时不会抛出异常。
        expectedModCount = modCount;
    } catch (IndexOutOfBoundsException ex) {
        throw new ConcurrentModificationException();
    }
}

上面两个问题的答案在注释中已经清晰地给出了。可以发现,迭代器的remove方法还是调用了ArrayListremove方法,只不过在调用之后,同步了内外两个修改次数的变量,所以不会抛出并发修改异常。

通过ArrayListremove方法来删除

下面这种直接调用ArrayListremove方法的代码也不会抛出异常:

java
jdk8-test/src/ant/lzip/net/collection/TestArrayList.java
public static <T> void testGoodRemove_2(ArrayList<T> list) {
    while(!list.isEmpty()) {
        /*
         * 两种方式都可以,第一种是从前删到尾;第二种是从尾删到前。
         */
        // list.remove(0);
        list.remove(list.size() - 1);
    }
}

但是要注意,别写出下面这种代码了,问题原因在注释中已经说明。

java
jdk8-test/src/ant/lzip/net/collection/TestArrayList.java
public static <T> void testBadRemove_2(ArrayList<T> list) {
    /*
     * 注意这种代码看似没有��题,其实只能删除前一半的元素。
     * 因为i和list.size()都在变动,这本身在语法上没有错误,但是在逻辑上是有问题的。
     */
    for (int i = 0; i < list.size(); i++) {
        list.remove(i);
    }
    // 结束结果为: list的原始容量 / 2
    System.out.println(list.size());
}

总结

本文总结了ArrayList的内部数据存储和扩容的原理,以及在循环中删除元素的注意事项。虽然内容比较简单,但是多总结总是好的。