通过源码重新认识Java集合迭代遍历增删元素时出现的ConcurrentModificationException及奇怪现象
前言
我们经常会在项目里遇到那种需要过滤无效数据的场景,较普通常规的做法就是在循环遍历的时候,将不符合要求的元素移出集合得到我们想要的结果。但是如果对集合的认识不到位的话,可能会抛出异常或者出现一些奇怪的现象哦,今天就一起重新认识一下。
一、场景设定
假设有一个集合list为[11、22、22、33],现在的要求是我们不需要其中的22元素,但是我们又不知道其中元素的顺序,该如何操作呢?
二、事故现场
2.1 代码抛异常ConcurrentModificationException
话不多说直接上错误示例代码,错误原因我们后面再分析
public static void test2For() {
System.out.println("增强for循环");
List<String> list = new ArrayList<String>(Arrays.asList("11","22","22","33"));
for (String i : list) {
// 用打印语句代表干了一些事儿
System.out.println("当前元素:" + i);
if ("22".equals(i)) {
list.remove(i);
}
}
System.out.println("处理后的集合为:" + list);
}
运行结果:
2.2 不报错,但是未达到预期结果
还是直接一样上错误示例代码:
public static void testfori() {
System.out.println("for i 循环");
List<String> list = new ArrayList<String>(Arrays.asList("11", "22", "22", "33"));
for (int i = 0; i < list.size(); i++) {
// 用打印语句代表干了一些事儿
System.out.println("当前元素:" + list.get(i));
if ("22".equals(list.get(i))) {
list.remove(i);
}
}
System.out.println("处理后的集合为:" + list);
}
运行结果:我们会发现第二个22并没有按照预期从集合中删除,这是为什么呢?往下一节看!
三、源码分析
3.1 为什么会抛出ConcurrentModificationException?
我们看一下2.1节当中的运行结果的堆栈信息:
增强for循环
当前元素:11
当前元素:22
Exception in thread "main" java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:859)
at java.util.ArrayList$Itr.next(ArrayList.java:831)
at com.chengcheng.CirculateDemo.test2For(CirculateDemo.java:56)
at com.chengcheng.CirculateDemo.main(CirculateDemo.java:12)
可以看到异常是在ArrayList$Itr
的checkForComodification()
方法抛出的,可能我们对这个并不熟悉,但是我们看到下一行是在next()
方法中调用的这个checkForComodification()
的。那我们来看一下这两个方法的源码:
看源码之前,还是要附加一句,怕有少数基础不好的小白会问:怎么会有next()方法呢?代码明明没有啊。这个就是因为咱们的增强for循环其实只是一个语法糖,他的本质其实还是迭代器,在代码编译的时候会将这种增强for循环编译成迭代器操作,我们可以看一下编译后的代码是什么样子的。
public static void test2For() {
System.out.println("增强for循环");
List<String> list = new ArrayList(Arrays.asList("11", "22", "22", "33"));
Iterator i$ = list.iterator();
while(i$.hasNext()) {
String i = (String)i$.next();
System.out.println("当前元素:" + i);
if ("22".equals(i)) {
list.remove(i);
}
}
System.out.println("处理后的集合为:" + list);
}
好了,回归正题,我们看next()
和checkForComodification()
的源码
@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];
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
代码很清晰,在checkForComodification()
方法中,做了一个modCount
和expectedModCount
相等比较操作,当二者不相等时,则会抛出ConcurrentModificationException
异常。
顺藤摸瓜,这两个参数又是干什么的呢?
首先,看一下AbstractList
中官方对modCount
参数的解释(见下文),看不懂英文不要紧,大概的意思就是说:这个参数是用于记录list的集合大小发生过变化(修改)的次数,它会被iterator迭代器使用(即前面我们看到的checkForComodification
方法)用于实现一个快速失败的迭代器,防止出现奇怪的行为(如我们2.2的那种)。
附:JavaDoc官方注释
/**
* The number of times this list has been <i>structurally modified</i>.
* Structural modifications are those that change the size of the
* list, or otherwise perturb it in such a fashion that iterations in
* progress may yield incorrect results.
*
* <p>This field is used by the iterator and list iterator implementation
* returned by the {@code iterator} and {@code listIterator} methods.
* If the value of this field changes unexpectedly, the iterator (or list
* iterator) will throw a {@code ConcurrentModificationException} in
* response to the {@code next}, {@code remove}, {@code previous},
* {@code set} or {@code add} operations. This provides
* <i>fail-fast</i> behavior, rather than non-deterministic behavior in
* the face of concurrent modification during iteration.
*
* <p><b>Use of this field by subclasses is optional.</b> If a subclass
* wishes to provide fail-fast iterators (and list iterators), then it
* merely has to increment this field in its {@code add(int, E)} and
* {@code remove(int)} methods (and any other methods that it overrides
* that result in structural modifications to the list). A single call to
* {@code add(int, E)} or {@code remove(int)} must add no more than
* one to this field, or the iterators (and list iterators) will throw
* bogus {@code ConcurrentModificationExceptions}. If an implementation
* does not wish to provide fail-fast iterators, this field may be
* ignored.
*/
protected transient int modCount = 0;
这个参数什么时候会发生变化呢? 那当然是集合大小发生变化的时候啊,比如add、remove方法,我们拿remove方法的源码来看看,你就会看到一个modCount ++
的操作,同理在add方法中也能看到类似的操作。
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;
}
看完modCount
,我们看看另外一个参数expectedModCount
/**
* An optimized version of AbstractList.Itr
*/
private class Itr implements Iterator<E> {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;
// 以下省略其他源码
}
我们看到官方并没有对这个参数做过多的解释,从字面的意思来看就是指集合预期的修改次数,并且它在初始化的时候是与modCount
相等的。**那么为什么要检查这两个参数是否相等呢?**其实啊,相信你心里可能已经有了答案,我们上面提到的2.2不就是最好的例子嘛,不检查的结果就是程序运行达不到预期的结果,但是感觉上业务逻辑似乎并没有什么问题,这就很疑惑了对吧。我们可以再具体看一下官方对ConcurrentModificationException
的定义和介绍。
3.2 ConcurrentModificationException是什么?
ConcurrentModificationException
中文直译过来就是并发修改异常。官方是这么解释的:
/**
* This exception may be thrown by methods that have detected concurrent
* modification of an object when such modification is not permissible.
* <p>
* For example, it is not generally permissible for one thread to modify a Collection
* while another thread is iterating over it. In general, the results of the
* iteration are undefined under these circumstances. Some Iterator
* implementations (including those of all the general purpose collection implementations
* provided by the JRE) may choose to throw this exception if this behavior is
* detected. Iterators that do this are known as <i>fail-fast</i> iterators,
* as they fail quickly and cleanly, rather that risking arbitrary,
* non-deterministic behavior at an undetermined time in the future.
* <p>
* Note that this exception does not always indicate that an object has
* been concurrently modified by a <i>different</i> thread. If a single
* thread issues a sequence of method invocations that violates the
* contract of an object, the object may throw this exception. For
* example, if a thread modifies a collection directly while it is
* iterating over the collection with a fail-fast iterator, the iterator
* will throw this exception.
*
* <p>Note that fail-fast behavior cannot be guaranteed as it is, generally
* speaking, impossible to make any hard guarantees in the presence of
* unsynchronized concurrent modification. Fail-fast operations
* throw {@code ConcurrentModificationException} on a best-effort basis.
* Therefore, it would be wrong to write a program that depended on this
* exception for its correctness: <i>{@code ConcurrentModificationException}
* should be used only to detect bugs.</i>
*/
对于英文不好的同学也不要急,我们大致说一下官方这段话的意思:这是一个当某个方法检查到指定对象发生并发修改,并且这种并发修改又是不允许的情况下抛出的异常。比如说当一个线程在迭代某个集合的时候,另外一个线程又同时在修改它。JDK实现的一些Iterator之所以做这个检查并抛出异常是为了达到一个快速失败的效果,从而避免得到一个不确定的结果。
当然官方在后面也说到了,这个异常并不一定抛出的时候是因为另外一个线程对对象进行了并发修改,当一个单线程破坏了方法调用的约定的时候,也是有可能报错的(比如我们2.1的这个例子就是单线程的并发修改)。通常这个异常是被用于检测代码BUG的。
3.3 为什么2.2案例没有达到预期?
其实原因很简单,因为我们是通过元素的下标来在遍历的时候获取元素的,但是,当我们使用了remove方法之后,这个list中的元素的下标就有可能会发生变化(为什么说是有可能?仔细想想),为什么发生变化呢?这跟ArrayList的实现有关,ArraysList本身是通过数组来实现的,所以当元素发生增删的时候,会对内部数组进行移位调整,看下方源码, 会看到一个System.arraycopy(elementData, index+1, elementData, index,numMoved);
的数组复制操作,这个操作就会使我们的移除的这个元素的后面的所有元素发生挪动(前移一位)导致元素的下标发生变化。当我们删除第一个22
元素的时候,第二个22
的元素会到前一个22
的位置,那么在下一次遍历的时候,下标指向的就是33
了,所以就相当于漏了第二个22
元素。
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;
}
四、场景解决办法及可行性原因
4.1 倒序删除
public static void testforiReverse() {
System.out.println("for i 循环");
List<String> list = new ArrayList<String>(Arrays.asList("11", "22", "22", "33"));
for (int i = list.size() - 1 ; i > 0; i--) {
// 用打印语句代表干了一些事儿
System.out.println("当前元素:" + list.get(i));
if ("22".equals(list.get(i))) {
list.remove(i);
}
}
System.out.println("处理后的集合为:" + list);
}
运行结果我们就不看了,为什么倒序删除就可以呢?我们3.3中讲到了,当正序删除时,list会做一个arraycopy的动作,将删除元素后的所有元素往前挪动一位,也就是说,删除元素前面的所有元素下标是不会发生变化的。所以当我们采用倒序删除的时候,前面的元素下标没有发生变化,自然就会得到正确的结果。
4.2 利用纯迭代器操作
public static void testIter() {
System.out.println("迭代器循环");
List<String> list = new ArrayList<String>(Arrays.asList("11", "22", "22", "33"));
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String ele = it.next();
// 用打印语句代表干了一些事儿
System.out.println("当前元素:" + ele);
if ("22".equals(ele)) {
it.remove();
}
}
System.out.println("处理后的集合为:" + list);
}
迭代器的remove
方法为什么可以做到呢?迭代器的cursor
参数会在list.remove
之后也会相应的发生变化,这样后面的元素的新下标也会同步给迭代器,所以不会出现2.2那种奇怪的元素遗漏未处理的现象了。那为什么不会抛异常呢?经过前面的了解,我们知道当modCount
和expectedModCount
不一致的时候才会抛出异常,那么我们来看看迭代器的remove方法源码就知道了,它在remove
之后还修改了这两个参数,使之相等的,所以迭代器的删除操作是不会发生异常的。
附源码:
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();
}
}
4.3 Java8的StreamAPI
public static void testStreamAPI(){
System.out.println("Stream API");
List<String> list = new ArrayList<String>(Arrays.asList("11","22","22","33"));
list = list.stream().filter(ele -> !"22".equals(ele)).collect(Collectors.toList());
System.out.println(list);
}
java8这个Stream API是真的好使,简简单单就把事儿干了。
好了今天就到这里,欢迎关注我的公众号“Bug洞洞”,一起记录踩坑、学习过程,虽然我是个不常更新的主儿,哈哈哈。