Java foreach中List移除元素抛出ConcurrentModificationException原因全解析

一、背景

本文重点探讨 foreach 循环中移除元素造成 java.util.ConcurrentModificationException 异常的原因。

先看《阿里巴巴 Java开发手册》中的相关规定:

Java foreach中List移除元素抛出ConcurrentModificationException原因全解析

那么思考几个问题:

    • 反例的运行结果怎样?
    • 造成这种现象的根本原因是什么?
    • 有没有更优雅地的移除元素姿势?

    本文将为你深度解读该问题。

    二、解读

    2.0 反例源代码

    public class ListExceptionDemo {
        public static void main(String[] args) {
            List<String> list = new ArrayList<>();
            list.add("1");
            list.add("2");
            for (String item : list) {
                if ("1".equals(item)) {
                    list.remove(item);
                }
            }
        }
    }

    Java foreach中List移除元素抛出ConcurrentModificationException原因全解析

    2.1 反例的运行结果

    当 if 的判断条件是 “1”.equals(item) 时,程序没有抛出任何异常。

    if ("1".equals(item)) {
            list.remove(item);
     }

    Java foreach中List移除元素抛出ConcurrentModificationException原因全解析

    而当判断条件是 :"2".equals(item)时,运行会报 java.util.ConcurrentModificationException。

    2.2 原因分析

    2.2.1 错误提示

    既然报错,那么好办,直接看错误提示呗。

    Exception in thread "main" java.util.ConcurrentModificationException

        at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)

        at java.util.ArrayList$Itr.next(ArrayList.java:859)

        at com.chujianyun.common.collection.list.ListExceptionDemo.main(ListExceptionDemo.java:13)

    ConcurrentModificationException? 并发修改异常? 一个线程哪来的并发呢?

    对应的时序图

    Java foreach中List移除元素抛出ConcurrentModificationException原因全解析

    然后我们通过错误提示看源码:我们看到错误的原因是执行 ArrayList的 Itr.next 取下一个元素检查 并发修改是

    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];
     }

    Java foreach中List移除元素抛出ConcurrentModificationException原因全解析

    modCount 和 expectedModCount不一致导致的:

    final void checkForComodification() {
     if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
     }

    Java foreach中List移除元素抛出ConcurrentModificationException原因全解析

    因此可以推测出发生异常的根本原因在于:取下一个元素时,检查 modCount,发现不一致。

    2.2.2 代码调试法

    为了验证上面的推测,大家可以在上述两个关键函数上打断点,通过单步了解程序的运行步骤。

    我们通过调试可以“观察到”,ArrayList中的 foreach 循环的语法糖最终迭代器Array$Itr 实现的。

    通过断点我们发现,ArrayList 构造内部类 Itr 对象时 expectedModCount 的值为 ArrayList的 modCount。 

    运行 next 函数时会检查List 中的 modCount 的值 和 构造迭代器时“备份的” expectJava foreach中List移除元素抛出ConcurrentModificationException原因全解析edModCount 是否相等。


    通过调试我们还发现:虽然原始 list 至于两个元素,for each 循环执行两次后,满足if 条件移除 值为“2”的元素之后, foreach 循环依然可以进入,此时会再次通过 next 取出 list中的元素,又会执行  checkForComodification函数检查上述两个值是否相等,此时不等,抛出异常。

    Java foreach中List移除元素抛出ConcurrentModificationException原因全解析

    那么这里有存在两个问题:

      1. 为什么 List 为 2  , next 却执行了 3 次呢?
      2. 如果不通过调试我们怎么知道 foreach 语法糖的底层如何实现的呢?

      带着这两个问题,我们继续深入研究下去。

      2.2.3  源码解析

      我们查看  ArrayList$Itr 的 hasNext 函数:

      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;
              Itr() {}
              public boolean hasNext() {
                  return cursor != size;
              }
      // 其他省略
      }

      Java foreach中List移除元素抛出ConcurrentModificationException原因全解析

      发现ArrayList的迭代器判断是否有下一个元素的标准是将下一个待返回的元素的索引和 size 比,不等表示还有下一个元素。

      我们重新看源码:

      public static void main(String[] args) {
              List<String> list = new ArrayList<>();
              list.add("1");
              list.add("2");
              for (String item : list) {
                  if ("2".equals(item)) {
                      list.remove(item);
                  }
              }
          }

      Java foreach中List移除元素抛出ConcurrentModificationException原因全解析

      最初 List 中有两个元素,expectedModCount 值为2。

      遍历第一个时没有走到if, 遍历第二个元素时走到if ,通过 List.remove 函数移除了元素。

      public boolean remove(Object o) {
              if (o == null) {
                  for (int index = 0; index < size; index++)
                      if (elementData[index] == null) {
                          fastRemove(index);
                          return true;
                      }
              } else {
                  for (int index = 0; index < size; index++)
                      if (o.equals(elementData[index])) {
                          fastRemove(index);
                          return true;
                      }
              }
              return false;
          }

      Java foreach中List移除元素抛出ConcurrentModificationException原因全解析

      而remove会调用 fastRemove 函数实际移除掉元素,在此函数中会将 modCount+1,即 modCount的值为3。

      private void fastRemove(int index) {
              modCount++;
              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
          }

      Java foreach中List移除元素抛出ConcurrentModificationException原因全解析

      因此在次进入foreach 时,expectedModCount 值 和 modCount的值 不相等,因此认为还有下一个元素。

      但是调用迭代器的 next 函数时需检查两者是相等,发现不等,抛出ConcurrentModificationException异常。

      当 if条件是  “1”.equals(item)时

      public static void main(String[] args) {
              List<String> list = new ArrayList<>();
              list.add("1");
              list.add("2");
              for (String item : list) {
                  if ("1".equals(item)) {
                      list.remove(item);
                  }
              }
          }

      Java foreach中List移除元素抛出ConcurrentModificationException原因全解析

      循环取出第一个元素后直接通过list给移除掉了,再次进入 foreach循环时,通过 hashNext 判断是否有下一个元素时,由于 游标==1(此时list的 size),因此判断没下一个元素。

      也就是说此时循环只执行了一次就结束了,没有走到可以抛出ConcurrentModificationException异常的任何函数中,从而没有任何错误。

      读到这里对迭代器的理解是不是又深了一层呢?

      看到这里可能还有些同学对 foreach 究竟底层怎么实现的仍然一知半解,那么请看下一部分。

      2.2.4 反汇编

      话不多说,直接反汇编:

      public class com.chujianyun.common.collection.list.ListExceptionDemo {
        public com.chujianyun.common.collection.list.ListExceptionDemo();
          Code:
             0: aload_0
             1: invokespecial #1                  // Method java/lang/Object."<init>":()V
             4: return
        public static void main(java.lang.String[]);
          Code:
             0: new           #2                  // class java/util/ArrayList
             3: dup
             4: invokespecial #3                  // Method java/util/ArrayList."<init>":()V
             7: astore_1
             8: aload_1
             9: ldc           #4                  // String 1
            11: invokeinterface #5,  2            // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
            16: pop
            17: aload_1
            18: ldc           #6                  // String 2
            20: invokeinterface #5,  2            // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
            25: pop
            26: aload_1
            27: invokeinterface #7,  1            // InterfaceMethod java/util/List.iterator:()Ljava/util/Iterator;
            32: astore_2
            33: aload_2
            34: invokeinterface #8,  1            // InterfaceMethod java/util/Iterator.hasNext:()Z
            39: ifeq          72
            42: aload_2
            43: invokeinterface #9,  1            // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
            48: checkcast     #10                 // class java/lang/String
            51: astore_3
            52: ldc           #6                  // String 2
            54: aload_3
            55: invokevirtual #11                 // Method java/lang/String.equals:(Ljava/lang/Object;)Z
            58: ifeq          69
            61: aload_1
            62: aload_3
            63: invokeinterface #12,  2           // InterfaceMethod java/util/List.remove:(Ljava/lang/Object;)Z
            68: pop
            69: goto          33
            72: return
      }

      Java foreach中List移除元素抛出ConcurrentModificationException原因全解析

      代码偏移从 0 到 25 行实现下面这部分功能:

      List<String> list = new ArrayList<>();
              list.add("1");
              list.add("2");

      Java foreach中List移除元素抛出ConcurrentModificationException原因全解析

      从 26行开始我们发现底层使用迭代器实现,我们脑补后翻译回 Java代码大致如下:

      public static void main(String[] args) {
              List<String> list = new ArrayList<>();
              list.add("1");
              list.add("2");
              Iterator<String> iterator = list.iterator();
              while (iterator.hasNext()) {
                  String item = iterator.next();
                  if ("2".equals(item)) {
                      //iterator.remove();
                      list.remove(item);
                  }
              }
          }

      Java foreach中List移除元素抛出ConcurrentModificationException原因全解析

      大家运行“翻译”后的代码发信啊和原始代码的报错内容完全一致:

      Exception in thread "main" java.util.ConcurrentModificationException

          at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)

          at java.util.ArrayList$Itr.next(ArrayList.java:859)

          at com.chujianyun.common.collection.list.ListException.main(ListException.java:16)

      2.2.5 继续深挖

      1、为啥通过 iterator.remove() 移除元素就没事呢?

      我们看 java.util.ArrayList.Itr#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();
           }
      }

      Java foreach中List移除元素抛出ConcurrentModificationException原因全解析

      从这里我们看到,通过迭代器移除元素后, expectedModCount 会重新赋值为 modCount。

      因此使用iterator.remove() 移除元素不报错的原因就找到了

      2、有没有比手册给出的代码更优雅的写法?

      我们打开其函数列表,观察List 和其父类有没有便捷地移除元素方式:

      Java foreach中List移除元素抛出ConcurrentModificationException原因全解析

      “惊奇”地发现,Collection 接口提供了 removeIf 函数可以满足此需求。

      还等啥呢,替换下,发现代码如此简洁:

      public static void main(String[] args) {
              List<String> list = new ArrayList<>();
              list.add("1");
              list.add("2");
              // 一行代码实现
              list.removeIf("2"::equals);
          }

      Java foreach中List移除元素抛出ConcurrentModificationException原因全解析

      自此是不是文章就该结束了呢? 

      NO..  

      removeIf 为啥能够实现移除元素的功能呢?

      我们猜测,底层应该是遍历然后对比元素然后移除,可能也是迭代器方式,我们看源码:

      java.util.Collection#removeIf

      default boolean removeIf(Predicate<? super E> filter) {
              Objects.requireNonNull(filter);
              boolean removed = false;
              final Iterator<E> each = iterator();
              while (each.hasNext()) {
                  if (filter.test(each.next())) {
                      each.remove();
                      removed = true;
                  }
              }
              return removed;
          }

      Java foreach中List移除元素抛出ConcurrentModificationException原因全解析

      我们发现和我们想的比较一致。

      三、总结

      本小节对《阿里巴巴 Java开发手册》中 foreach 循环中使用 List移除元素 导致并发修改异常的问题进行了全面深入地剖析。

      希望可以帮助大家,彻底搞懂这个问题。

      另外也提供了研究类似问题的一般思路,即代码调试、读源码、反汇编等。

      另外希望大家遇到问题时,能够养成深挖的精神,通过问题带动知识的理解,知其所以然。

      最后 尽信书不如无书,不要止步于书中提到的内容,要多一些思考。

      上一篇:Giraph 源码分析(五)—— 加载数据+同步总结


      下一篇:iOS14 适配canOpenURL问题