假设现在需要写这样一个最简单不过的程序:创建一个ArrayList,将3个元素加入其中,然后用Iterator遍历ArrayList将其输出。初学Java时,我们可能会写出如下错误代码:
public class Test {
public static void main(String[] args) {
ArrayList<Integer> arrayList = new ArrayList<>();
Iterator iterator = arrayList.iterator();
arrayList.add(1);
arrayList.add(2);
arrayList.add(3);
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
}
}
试着运行一下,发现报ConcurrentModificationException异常:
可能会觉得奇怪,明明没有并发修改,为什么会报这个异常?我们进入源码看看到底发生了什么
我们在错误代码中调用了arrayList.add(1)
来向集合中加入元素,来看看add(E e)方法的源码:
我们发现add(E e)方法中size和elementData都是ArrayList的成员变量:
size表示集合的大小,即ArrayList中元素的数目,而ArrayList实际上是使用Object[]数组elementData来保存元素的。
因此add(E e)方法中elementData[size++] = e
即是在数组末尾添加元素e,同时使数组长度加1。那么我们不禁想问,如果add元素时当前数组已满,数组elementData是如何扩容的呢,elementData初始的大小又是多少?答案会不会在ensureCapacityInternal(size + 1)
中?
我们来看看ensureCapacityInternal
方法:
DEFAULTCAPACITY_EMPTY_ELEMENTDATA
是啥?我们发现它表示一个空的Object[]数组,并在ArrayList的无参构造时将其赋给elementData:
ensureCapacityInternal
方法中出现的常量DEFAULT_CAPACITY的值为10。我们可以进一步发现ensureCapacityInternal
方法出现在所有的add和addAll方法中,当ArrayList被无参构造初始化时,其elementData为一个空Object[]数组的引用,当用不同的参数调用add或addAll方法,都会调用ensureCapacityInternal
方法,给其传递的参数为size + 新加入的元素个数
,调用add方法新加入的元素个数就是1,调用addAll方法新加入的元素个数为参数中集合的元素个数。ensureCapacityInternal
方法的参数minCapacity
表示集合所需的最小容量(当集合元素个数>=10时,最小容量即正好装入所有元素,当集合元素个数<10时,最小容量为10,即最小容量有多余),当ArrayList刚被无参构造初始化,它第一次通过add或addAll方法加入元素时,都会计算一个minCapacity
,它最小为10,即使我们初始化后第一次add就加了一个元素,minCapacity
的值也为10。
来看看minCapacity
有什么用,在ensureCapacityInternal
方法中minCapacity
最后作为了ensureExplicitCapacity
方法的参数,来看看ensureExplicitCapacity
方法:
逻辑很明显了,如果我们现在需要的集合容量比现有的大,那么调用grow方法,看名字就能猜到这是去扩容了,来看看grow方法:
扩容方法grow的逻辑是:若集合所需的最小容量minCapacity
大于当前集合大小的1.5倍(当前所需要的容量很大),则调用Arrays.copyOf()
方法返回大小为minCapacity
的新的数组对象(复制了原数组的已有元素),并使elementData成为该新数组的引用。若集合所需的最小容量minCapacity
较小,小于等于当前集合大小的1.5倍,则干脆多给它点空间,将数组扩容到原来大小的1.5倍(为什么这样做源码注释里已经写了,一般我们添加元素都是一个个加,因此所需容量和原容量相差不大,直接多给点容量,可以减少反复扩容调用Arrays.copyOf()方法的开销)。若minCapacity
非常大,大于Integer.MAX_VALUE - 8,则调用hugeCapacity
方法将集合大小调到最大:Integer.MAX_VALUE,即ArrayList大小最大为Integer.MAX_VALUE
根据以上逻辑,我们可以推断出ArrayList初始化之后,第一次调用add方法加入一个元素后,它内部数组的大小一定为10。
回到错误代码,首先我们调用了arrayList.iterator()
,返回了一个实现了Iterator接口的对象,我们来看看这个方法的源码:
我们发现,调用arrayList.iterator()
方法,实际上是通过无参构造给我们返回了一个Itr对象,ltr对象是个什么鬼?继续往下看:
我们看到Itr是ArrayList的一个内部类,它实现了Iterator接口,即实现了hasNext()、next()、remove()、forEachRemaining(Consumer super E> action)
四个方法,同时它还有自己的方法checkForComodification()
在Itr内定义了3个int类型的成员变量,cursor
表示下一个元素的下标,lastRet
表示上一个元素的下标,初始为-1,expectedModCount
暂时看不出表示什么(实际上它是造成错误代码抛异常的关键所在),我们接着看看hasNext()、next()
方法的实现:
hasNext()
方法的实现十分简单,cursor
表示下一个元素的下标,那么它不等于elementData的大小size时,即容器内还有可供访问的元素,返回true,否则元素已被遍历完,返回false。
再来看看next()
方法:
逻辑也十分简单,返回数组中cursor指示下标对应的元素,并将lastRet设为当前返回元素的下标,并将cursor向后移一位,即cursor + 1。不过在next()方法的最开头,有个checkForComodification()
方法,它是不是造成例子中错误代码抛异常的关键呢?来看看这个方法:
逻辑非常简单,只要modCount != expectedModCount
,就抛ConcurrentModificationException
异常,expectedModCount
作为Itr的成员变量,在成员变量初始化时就被赋成了modCount
。ArrayList继承了AbstractList抽象类,而modCount
是AbstractList的成员变量:
源码注释中指明了modCount
表示list被结构性修改的次数,结构性修改是指改变了list的大小(比如add、remove等操作),或者以其他方式扰乱list,以致迭代器正在进行的迭代可能会产生不正确的结果。如果此字段的值意外更改,则迭代器将抛出ConcurrentModificationException异常。通过modCount
提供了fail-fast机制,在迭代期间面对并发修改时会马上抛出异常,而不是进行非确定性行为。
我们再来回看一下add(E e)方法、ensureCapacityInternal
方法以及ensureExplicitCapacity
方法:
add(E e)方法会调用ensureCapacityInternal
方法,进而调用ensureExplicitCapacity
方法,在这个方法中,modCount进行加1,表示进行了一次结构性修改。
我们在错误代码中调用了三次add方法,modCount从最初的0变到3
回看一下ArrayList的内部类ltr,它的成员变量expectedModCount在初始化时被我们赋成了0(因为在调用arrayList.iterator()
无参构造Itr对象时,此时arrayList并没有进行任何结构性修改,它所继承的成员变量modCount = 0)
再来看看next方法中调用的checkForComodification()
方法,造成异常的原因已经被我们找到了
当我们执行next()方法时,ltr对象内部的expectedModCount = 0,而实际的modCount = 3,因此抛出了并发修改异常。
也就是说在arrayList.iterator()
方法调用创建出ltr对象后,对arrayList的add操作都对该ltr对象不可见,若先创建了ltr对象,后对arrayList进行add操作,在调用next()方法遍历集合元素时会抛出异常
如何修改例子中的错误代码呢?非常简单,在add方法之后再调用arrayList.iterator()
方法创建迭代器Iterator即可,这样ltr对象内部的expectedModCount = 3,而实际的modCount = 3,两者相等
public class Test {
public static void main(String[] args) {
ArrayList<Integer> arrayList = new ArrayList<>();
arrayList.add(1);
arrayList.add(2);
arrayList.add(3);
Iterator iterator = arrayList.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
}
}
通过之前的源码分析,我们知道了在arrayList.iterator()
方法调用创建出ltr对象后,对arrayList的add操作都对该ltr对象不可见,若先创建了ltr对象,后对arrayList进行add操作,在调用next()方法遍历集合元素时会抛出异常。那么迭代器的remove()方法也和add(E e)方法一样吗,按照我们的经验,迭代器的remove()方法不会有上述特性,因为我们可以边用迭代器Iterator遍历,边删除元素:
public class Test {
public static void main(String[] args) {
ArrayList<Integer> arrayList = new ArrayList<>();
arrayList.add(1);
arrayList.add(2);
arrayList.add(3);
arrayList.add(4);
Iterator<Integer> iterator = arrayList.iterator();
while (iterator.hasNext()) {
int item = iterator.next();
if (item % 2 == 0) {
iterator.remove();
}
}
iterator = arrayList.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
}
}
迭代器的remove()方法删除迭代器最近访问的元素(实际上是lastRet
指向的上一个元素),上述代码中,我们删除了偶数,只输出了剩余的奇数。注意我们在输出剩余元素之前先要重新调用arrayList.iterator()
方法,原因经过之前的源码分析已经很清楚了,需要重新返回一个ltr对象,重置它的cursor等成员变量,否则我们将得不到输出
我们通过源码看看迭代器的remove()方法到底有何特殊之处:
调用迭代器的remove()方法,实际上还是调用ArrayList的remove(int index)方法,不过传入的参数index = lastRet,表示上一个元素的下标。在remove(int index)方法中,首先将结构性修改次数modCount加1,然后进入删除的过程,删除的过程十分简单粗暴,若数组大小>1,我们要删除下标为index的元素,那么我们将index之后的所有元素都往前挪一位,这样原下标为index的元素被下标为index + 1的元素所替代,之后的元素也是以此类推,最后会空出原最后一个元素所占的位置,将其置为null,等着被GC。若数组大小为1,则直接将elementData[0]赋为null即可,最后数组大小size - 1,返回被删除的元素。
删除完元素返回到迭代器的remove()方法后,看看迭代器的remove()方法接下来做了什么,它首先将cursor=lastRet
,这样cursor指向所删元素的下一个元素,然后将lastRet=-1
,表示刚刚访问的元素已经不存在了(被删除了),接下来就关键了:expectedModCount = modCount
,这表明在迭代器的remove()方法中,ltr对象的成员变量expectedModCount
会与modCount
进行同步,这样我们下次执行next()方法时将不会抛出异常,因此在arrayList.iterator()
方法调用创建出ltr对象后,对arrayList执行迭代器的remove()方法都是对该ltr对象可见的,这是迭代器的remove()方法和其add方法之间的重要差别。
为什么要这样设计的道理也很简单,因为迭代器的remove()删除操作是很大可能和next操作配合完成的,我们一般都是遍历整个集合的元素,将一些特定元素删除,迭代器的remove()方法必须能和next方法配合使用,那么它一定要进行expectedModCount
与modCount
的同步,否则将抛出异常。而add方法是极小概率会和next方法配合的,如果造成了文章最开头例子中的错误,那就是初学java不熟练的原因了
foreach循环作为语法糖,可以替代迭代器进行集合的遍历,那么它是通过迭代器实现的吗?我们还是来删除ArrayList中的偶数,保留奇数,这次我们用foreach循环实现:
public class Test {
public static void main(String[] args) {
ArrayList<Integer> arrayList = new ArrayList<>();
arrayList.add(1);
arrayList.add(2);
arrayList.add(3);
arrayList.add(4);
for (Integer item : arrayList) {
if (item % 2 == 0) {
arrayList.remove(item);
}
}
for (Integer item2 : arrayList) {
System.out.println(item2);
}
}
}
执行一下,我们的老朋友ConcurrentModificationException异常又出现了:
什么情况?难道和foreach循环语法糖的实现有关吗?我们用XJad工具反编译上述代码,解除语法糖,看看foreach循环是怎么实现的:
我们发现foreach循环其实就是帮我们封装了迭代器iterator遍历集合的过程,它是用迭代器iterator实现的。那么为什么会抛异常呢,注意当我们在错误代码中使用foreach循环时,我们使用的是ArrayList的remove(Object object)方法,而不是使用迭代器的remove()方法,这两者有什么不同?我们来看看ArrayList的remove(Object object)方法源码:
逻辑十分简单,若传入参数为null,则遍历数组elementData,找到第一个为null的元素,将其删除,若传入参数不为空,同样也是遍历数组,然后找到第一个需删除的元素。删除元素调用的是fastRemove方法:
和我们之前看到的ArrayList的remove(int index)方法删除原理相同,通过元素统一往前挪一位实现删除,同样将结构性修改次数modCount加1,至于为什么单独分出这个方法,源码注释中已经提到了,这个方法与ArrayList的remove(int index)方法的不同之处在于不进行边界判断以及不返回被删除的元素,所以叫fastRemove。
我们又看到了什么不得了的东西:modCount++
,之前所有错误代码抛出并发修改异常总和这个modCount
有关,这次也是这样。我们调用ArrayList的remove(Object object)方法,迭代器ltr中的expectedModCount
这次又没有和modCount
同步,因此在调用ArrayList的remove(Object object)方法删除元素后,再调用迭代器的next()方法,抛出了异常。
阿里Java开发规范中有一条强制性规定:
经过之前的源码分析,这条强制性规范也就不难理解了。在foreach循环中调用ArrayList的remove或add方法进行元素的remove/add操作是一个非常危险的行为,一定会抛出ConcurrentModificationException异常,因为调用ArrayList的remove或add方法,modCount
会加1,而ltr中的expectedModCount
不变,在之后调用迭代器的next()方法时会抛出异常。这样的代码危险之处在于IDEA不会这么智能,给我们提示说代码会抛异常,如果安装了阿里编码规范插件,插件也判断不出这个错误(代码还做不到这么智能),因此如果在项目中使用错误代码而没有做单元测试的话,就等着背锅吧。
remove元素时为什么要使用Iterator迭代器,也很容易理解了。因为ltr的remove()方法会将自己的expectedModCount
与modCount
同步,从而支持之后调用next()方法,不会抛出异常。
并发操作为啥要给Iterator对象加锁?Iterator迭代器并不是线程安全的,不给它加锁,ltr中的expectedModCount、cursor
和ArrayList中的modCount
等就都乱套了,一定会抛ConcurrentModificationException异常