ConcurrentModifcationException 的 what、why、how

本文探讨了Java和Kotlin中遍历列表时遇到ConcurrentModificationException的问题,通过实例代码、字节码分析和Kotlin的对比,揭示了for和foreach的区别,以及如何避免在遍历过程中修改集合以防止异常。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

闲聊

有时我们在遍历列表时,可能存在删除元素的操作,亦或在遍历过程中,存在其他地方并发删除列表的操作,经过自信的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();
}

从上面代码发现

  1. modCount != expectedModCount 时会出现崩溃。

继续跟踪发现

  1. modCount是ArrayList的成员变量。
  2. expectedModCount是ArrayList内部类Itr的成员变量,在Itr初始化时被赋值为ArrayList#modCount,
  3. 从代码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,导致遍历的上限一直在改变,由此可见,某些情况下区间会变化,这可能对某些代码造成影响,例如:

  1. 如果一个列表size==5,我在遍历时需要打印1-5,但遍历时出现一次并发删除 或 一次并发添加,因每次循环都会检查size,使得遍历区间一直在改变如[0, 4)、[0, 6),导致打印不如预期
  2. 还有另一种情况如下:
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打印,结果少了一
//-> 零
//-> 二
//-> 三
//-> 四
//-> 五

咦?一呢?跑哪去了?

原因如下

  1. 第一次遍历:i==0, list.size() == 6, list.get(0) == 零  

    1. 这里将命中删除,list将是 [一, 二, 三, 四, 五]

  2. 第二次遍历:i==1, list.size() == 5, list.get(1) == 二

    1. 因为这是第二次循环了,i==1,所以list中的 “一” 在遍历打印 + 并发添加中丢失了

倒序

倒序的话

  1. 针对上面第一种情况,倒序自遍历开始,区间就已确定: [0, 5),因为右区间已经定死,左区间也是确定的,不存在便利过程中区间变化的情况,但是使用这种情况,个人感觉需要加个try catch,如下代码所示
    1. 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
      }
  2. 针对上面第二种情况
    1. 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")
}

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值