ArrayList是Java开发中最常使用的集合之一。剖析内部使用实现机制,在应用中能够避免一些使用问题,更能得心应手。
扩容机制
首先来看ArrayList的add方法。
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方法来确保添加元素前数组的容量是足够的。
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对象时没有传递初始容量。
/**
* 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。这样在扩容操作的时候就知道需要直接扩容到初始容量。
/**
* 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);
}
在实际使用的时候,如果事先知道数据的容量,最好使用有参构造方法来一次性初始化到目标容量。这样可以避免在中途进行扩容,扩容涉及内存拷贝,如果数据量大了,会很耗费时间,导致应用性能下降。
下面来看扩容机制最核心的实现:
/**
* 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循环删除元素抛异常分析
为什么下面的代码会抛出异常?
public static <T> void testBadRemove(ArrayList<T> list) {
for (T i : list) {
// 问题1: 会抛出ConcurrentModificationException这个异常
list.remove(i);
}
}
可能直接看代码不知道for-each循环背后发生了什么,对class文件进行反编译后可以发现,调用了迭代器实现。
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。
public Iterator<E> iterator() {
// 创建ArrayList内部的迭代器实现
return new Itr();
}
先来看看Itr类的属性:
// 下一个需要返回的元素的下标
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方法。
@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这个方法中,看看里面做了什么。
final void checkForComodification() {
/*
* 如果内部的修改次数不等于外部的修改次数,
* 也就是说在迭代的过程中使用了当前迭代器外部的方法对ArrayList进行了修改,
* 那么会破坏当前迭代器内的字段,比如cursor和lastRet,会导致当前迭代器的操作出现歧义,所以这里会直接并发修改异常。
*/
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
这个方法在检查迭代器内部的修改次数和外部ArrayList中的修改次数是否一致,否则抛出异常。那为什么上面导致了这两个变量不一致呢?这要回过头看看上面循环中调用的ArrayList的remove方法。
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方法,要么只使用ArrayList的remove方法,两个不能混用。
通过迭代器的remove方法来删除
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方法?
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方法还是调用了ArrayList的remove方法,只不过在调用之后,同步了内外两个修改次数的变量,所以不会抛出并发修改异常。
通过ArrayList的remove方法来删除
下面这种直接调用ArrayList的remove方法的代码也不会抛出异常:
public static <T> void testGoodRemove_2(ArrayList<T> list) {
while(!list.isEmpty()) {
/*
* 两种方式都可以,第一种是从前删到尾;第二种是从尾删到前。
*/
// list.remove(0);
list.remove(list.size() - 1);
}
}
但是要注意,别写出下面这种代码了,问题原因在注释中已经说明。
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的内部数据存储和扩容的原理,以及在循环中删除元素的注意事项。虽然内容比较简单,但是多总结总是好的。