闲聊
有时我们在遍历列表时,可能存在删除元素的操作,亦或在遍历过程中,存在其他地方并发删除列表的操作,经过自信的Review and Run,蹦。。。ConcurrentModifcationException。。。
这时候我们会开始度娘:为什么会这样?怎么解决?原理是什么?
我们主要围绕ConcurrentModifcationException进行what、why、how死亡三问,基于Java测试代码 + 源码 + 字节码进行解答,别离开哦,这里,有你想要的答案。
问题复现
我们来通过两份代码重现问题场景
代码1
List<String> list = new ArrayList<>();
list.add("0");
list.add("1");
list.add("2");
list.add("3");
for (int i = 0; i < list.size(); i++) {
if(list.get(i) == "0") list.remove("1");
System.out.println("-> " + i);
}
运行结果
//-> 0
//-> 1
//-> 2
代码2
List<String> list = new ArrayList<>();
list.add("0");
list.add("1");
list.add("2");
list.add("3");
for (String i : list) {
if (i == "1") list.remove(i);
System.out.println("-> " + i);
}
运行结果
// -> 0
// -> 1
// Exception in thread "main" java.util.ConcurrentModificationException
// at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1043)
// at java.base/java.util.ArrayList$Itr.next(ArrayList.java:997)
// at com.example.test.JavaTest.main(JavaTest.java:19)
抛出问题
咦?为什么同样是遍历,代码2会出现ConcurrentModificationException,代码1不会?
两者有什么区别?
哦,一个是用fori,一个是用foreach语法糖,
咦?fori和foreach又有什么区别?
尝试理解
接下来我们来揭开fori、foreach的真面目,用什么?用字节码。(在AS中安装插件:jclasslib Bytecode Viewer,然后打开Java文件,AS左上角选择File > Show Bytecode With Jclassib)
经过操作,得到字节码如下
代码1字节码
代码2字节码
从字节码可以看到,代码1这种fori遍历,就是通过对本地变量不断循环赋值,然后执行中间操作,直到循环执行完成。
而代码2这种foreach遍历,从45行可以看到,其在字节码上体现为Iterator的遍历。
综上,看起来像是Iterator在遍历时执行了List#remove()就会出现问题?为什么呢?
我们先看看具体出现问题的地方,经过分析崩溃日志可以发现,崩溃出自ArrayList$Itr.next(),源码体现在:
public E next() {
checkForComodification(); // 这个方法有异常
int i = cursor;
if (i >= limit)
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();
}
从上面代码发现
- modCount != expectedModCount 时会出现崩溃。
继续跟踪发现
- modCount是ArrayList的成员变量。
- expectedModCount是ArrayList内部类Itr的成员变量,在Itr初始化时被赋值为ArrayList#modCount,
- 从代码2字节码45行可以看出来,Itr是在遍历前通过ArrayList#iterator()方法构建出来的。
那问题来了,modCount和expectedModCount既然在Itr初始化时是一样,那这两者又是什么时候不一样的嘞?经过前面测试代码发现,我们中间执行了ArrayList#remove方法。我看一起看看代码长啥样:
public E remove(int index) {
Objects.checkIndex(index, size);
final Object[] es = elementData;
@SuppressWarnings("unchecked") E oldValue = (E) es[index];
fastRemove(es, index);
return oldValue;
}
private void fastRemove(Object[] es, int i) {
modCount++;
final int newSize;
if ((newSize = size - 1) > i)
System.arraycopy(es, i + 1, es, i, newSize - i);
es[size = newSize] = null;
}
哟,发现modCount在这里+1了,且并未同步给expectedModCount,这就导致下一次循环中调用ArrayList$Itr.next()时发现两者不同,导致崩溃.
问题自此明了,那怎么解决这种问题呢?
解决办法:
1、遍历中有需要remove的,使用Iterator遍历 + Iterator#remove(),其内部会同步modCount给expectedModCount
2、遍历时为了避免受其他地方并发remove影响的,可以倒序遍历,如下
for (int i = list.size() - 1; i >= 0; i--) {
if (list.get(i) == "1") list.remove(i);
System.out.println("-> " + i);
}
咦?倒序?为什么是倒序?正序为什么就不行?
正序、倒序
正序
正序的话,上限是list.size() - 1,每次循环判断一次,并发删除时size-1,导致遍历的上限一直在改变,由此可见,某些情况下区间会变化,这可能对某些代码造成影响,例如:
- 如果一个列表size==5,我在遍历时需要打印1-5,但遍历时出现一次并发删除 或 一次并发添加,因每次循环都会检查size,使得遍历区间一直在改变如[0, 4)、[0, 6),导致打印不如预期
- 还有另一种情况如下:
List<String> list = new ArrayList<>();
list.add("零");
list.add("一");
list.add("二");
list.add("三");
list.add("四");
list.add("五");
for (int i = 0; i < list.size(); i++) {
System.out.println("-> " + list.get(i));
if(list.get(i) == "零") list.remove("零");
}
// result打印,结果少了一
//-> 零
//-> 二
//-> 三
//-> 四
//-> 五
咦?一呢?跑哪去了?
原因如下
-
第一次遍历:i==0, list.size() == 6, list.get(0) == 零
-
这里将命中删除,list将是 [一, 二, 三, 四, 五]
-
-
第二次遍历:i==1, list.size() == 5, list.get(1) == 二
-
因为这是第二次循环了,i==1,所以list中的 “一” 在遍历打印 + 并发添加中丢失了
-
倒序
倒序的话
- 针对上面第一种情况,倒序自遍历开始,区间就已确定: [0, 5),因为右区间已经定死,左区间也是确定的,不存在便利过程中区间变化的情况,但是使用这种情况,个人感觉需要加个try catch,如下代码所示
-
List<String> list = new ArrayList<>(); list.add("0、"); list.add("1、"); list.add("2、"); list.add("3、"); for (int i = list.size() - 1; i >= 0; i--) { // try list.remove("1、"); list.remove("0、"); // 这里删了2个,i从size-1逐一往下遍历,就会数组越界 System.out.println("-> " + list.get(i)); // catch IndexOutOfBoundsException }
-
- 针对上面第二种情况
-
List<String> list = new ArrayList<>(); list.add("零"); list.add("一"); list.add("二"); list.add("三"); list.add("四"); list.add("五"); for (int i = list.size() - 1; i >= 0; i--) { System.out.println("-> " + list.get(i)); if(list.get(i) == "二") list.remove("二"); } //result打印,结果正常 //-> 五 //-> 四 //-> 三 //-> 二 //-> 一 //-> 零
-
其他
Kotlin中的表现
-
Kotlin中的正常foreach遍历也是语法糖,最终还是Iterator遍历的,也会存在上面的问题,解决办法和上述一致
-
注意:Kotiln倒序遍历可以用如下代码,代码在变成字节码后与上面倒序遍历代码一致(非Iterator),有兴趣的可以用AS的Tools > Kotlin > Show Kotlin Bytecode 查看相关字节码验证
for (i in (list.size - 1) downTo 0) {
if(list[i] == "1") list.removeAt(i)
println("-> $i")
}