ConcurrentModifcationException 的 what、why、how

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

闲聊

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

<think>嗯,用户让我解释一下之前提供的Vector类代码的“what, why, how”。首先,我需要回忆一下这个代码的结构和功能。用户之前已经问过关于不同长度向量如何处理的问题,现在他们可能希望更深入地理解这个类的设计思路、目的和实现方法。 首先,确定“what”部分。这个Vector类是用来做什么的?看起来是创建一个向量对象,支持加法、点积等操作,并且能处理不同长度的向量。需要明确说明这个类的用途和主要功能。 然后是“why”,为什么需要这样的设计?可能用户想知道为什么要处理不同长度的向量,或者为什么选择补零而不是其他方法。需要解释补零在数学上的合理性,比如保持线性代数中的维度对齐,或者在实际应用中的场景,比如数据填充。 接下来是“how”,即如何实现这些功能。需要详细说明`_align_dimension`方法的作用,如何在加法和点积中使用它,以及各个魔法方法如`__add__`和`dot_product`的具体步骤。同时,可能需要提到异常处理,比如检查输入是否为Vector实例。 还要注意用户可能的隐藏需求。他们可能是在学习面向对象编程或者线性代数的基础,所以需要将解释与编程概念和数学原理结合起来。例如,补零在数学上相当于将向量扩展至高维空间,保持内积和加法的正确性。 此外,测试用例部分也需要提到,说明这些案例如何验证代码的正确性,尤其是不同长度和空向量的情况。用户可能关心代码的健壮性和边界条件处理。 要确保回答结构清晰,分点解释whatwhyhow,每个部分都简明扼要。可能还需要关联之前的回答,比如补零的方法已经在之前解释过,这里需要重申但不过于重复。同时,语言要尽量简单,避免过于技术性的术语,但又要准确无误。 最后,检查是否有遗漏的核心点,比如类的初始化、异常处理(如ValueError)、魔法方法的使用(__len__, __str__)等,这些都是类设计的重要组成部分,应该涵盖在解释中。</think>### 用 **What-Why-How** 框架解析这段代码: --- #### **What(代码是什么?)** 这是一个自定义的 `Vector` 类,实现了以下功能: - **数学向量**的抽象表示(例如 $[1, 2]$) - **加法运算**:支持不同长度的向量相加(短向量末尾补零对齐) - **点积运算**:同样支持不同长度的向量 - **维度对齐机制**:通过补零扩展短向量维度 - **异常处理**:确保操作对象类型合法 --- #### **Why(为什么需要这样设计?)** 1. **现实需求** - 实际应用中常遇到不同长度向量的运算(例如信号处理、缺失数据填充) - 数学上,补零操作可以看作将向量扩展至更高维度空间 2. **数学合理性** - 补零操作保持线性代数运算的维度对齐原则 (例如:$[1,2]$ 可视为 $[1,2,0,0]$ 的四维向量) - 点积运算要求维度一致,补零后公式仍有效: $$ \mathbf{a} \cdot \mathbf{b} = \sum_{i=1}^n a_i b_i $$ 3. **编程友好性** - 允许用户直观操作向量,无需手动对齐维度 - 通过面向对象封装,隐藏内部实现细节 --- #### **How(如何实现?)** 1. **核心机制:维度对齐 (`_align_dimension`)** ```python def _align_dimension(self, other): max_len = max(len(self), len(other)) padded_self = self.data + [0] * (max_len - len(self)) # 短向量补零 padded_other = other.data + [0] * (max_len - len(other)) return padded_self, padded_other ``` - **输入**:两个向量 - **处理**:将较短向量末尾补零至与较长向量相同长度 - **输出**:对齐后的两个列表(例如 $[1,2] → [1,2,0,0]$) 2. **加法运算 (`__add__`)** ```python def __add__(self, other): padded_self, padded_other = self._align_dimension(other) result_data = [x + y for x, y in zip(padded_self, padded_other)] return Vector(result_data) ``` - 对齐维度后,逐元素相加 - 示例:$[1,2] + [1,2,3,4] → [2,4,3,4]$ 3. **点积运算 (`dot_product`)** ```python def dot_product(self, other): padded_self, padded_other = self._align_dimension(other) product_sum = sum(x * y for x, y in zip(padded_self, padded_other)) return product_sum ``` - 对齐维度后,逐元素相乘并求和 - 示例:$[1,2] \cdot [1,2,3,4] = (1×1) + (2×2) + (0×3) + (0×4) = 5$ 4. **边界处理** - 空向量自动补零(例如 `Vector([]) + Vector([5,5]) → [5,5]`) - 类型检查确保操作对象合法(`isinstance(other, Vector)`) --- ### **总结** 这段代码通过 **维度对齐补零** 的核心策略,在保证数学合理性的前提下,实现了不同长度向量的灵活运算,同时通过面向对象设计提升了代码的可维护性和易用性。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值