Java—— ConcurrentModificationException(并发修改异常)

一、问题引入

ArrayList的四个基础操作——增删改查

初始化

static ArrayList students = new ArrayList();
//初始化
//添加部分学生对象实例
static {
    students.add(new Student("张三", "男", 17));
    students.add(new Student("李四", "男", 18));
    students.add(new Student("王五", "女", 19));
    students.add(new Student("云九", "男", 23));
}

查找:

//查找“云九”
    public static void find(){
        for (Student student : students) {
            if(student.getName().equals("云九")){
                System.out.println(student.toString());
            }
        }
    }

运行结果

Java—— ConcurrentModificationException(并发修改异常)_第1张图片

 

修改:

//找到“云九”,将其年龄修改为18
 //修改
    public static void modify(){
        for (Student student : students) {
            if(student.getName().equals("云九")){
                student.setAge(18);
                System.out.println(student.toString());
            }
        }
    }

运行结果

Java—— ConcurrentModificationException(并发修改异常)_第2张图片

 

添加:

//如果有“云九”,则添加一个学生“肖八”
//添加学生
    public static void add(){
        for (Student student : students) {
            if(student.getName().equals("云九")){
                students.add(new Student("肖八", "男", 22));
            }
        }
    }

运行结果

Exception in thread "main" java.util.ConcurrentModificationException
    at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:911)
    at java.util.ArrayList$Itr.next(ArrayList.java:861)

删除:

//找到“云九”,将其删除
  public static void delete(){
        for (Student student : students) {
            if(student.getName().equals("云九")){
                students.remove(student);
            }
        }
    }

运行结果

Exception in thread "main" java.util.ConcurrentModificationException
    at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:911)
    at java.util.ArrayList$Itr.next(ArrayList.java:861)

二、原因分析

由此,ArrayList的四种基本操作增删改查,其中的增和删都出现了ConcurrentModificationException,也就是并发修改异常,仔细查看抛出的异常,我们可以发现错误都指向了ArrayList内部类Itr的next方法当中的checkForComodification方法

 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();
        }

看到next方法,首先联想到迭代器Iterator,但是以上都是通过for-each进行遍历的,所以这里还涉及到for-each的内部实现问题, for-each的底层其实也是通过迭代器实现的(java foreach内部实现_山间明月江上清风_的博客-CSDN博客),也就是说当我们在使用简洁for循环或者通过迭代器遍历时,都会先调用迭代器的next方法,在next方法中又会首先调用checkForComodification()方法进行检查,当这个方法检查到modCount与expectedModCount不相等时,就会抛出并发修改异常。

由此可知,并发修改异常的实质是一个名为modCount的变量值不再等于expectedModCount的变量值

modCount

Java—— ConcurrentModificationException(并发修改异常)_第3张图片

 

进一步查看源码可以知道modCount是定义在AbstractList抽象类中的成员变量,而ArrayList是此类的子类,所以ArrayList继承到了modCount这个变量

源码中对其的解释为:

  • modCount代表该列表在结构上被修改的次数。如果该字段的值发生意外变化,迭代器将抛出并发修改异常,以响应next、remove、set或add等对集合执行的操作。

expectedModCount

  • expectedModCount是ArrayList内部类Itr中的成员变量,当ArrayList对象调用iteroter()方法时,会自动创建内部类Itr的对象,并给其成员变量expectedModCount赋初始值,初始值为modCount!即当最初调用迭代器方法时,expectedModCount 和 modCount 值的是默认相等的

Java—— ConcurrentModificationException(并发修改异常)_第4张图片

 

关系:

  • 当我们创建ArrayList对象的时候,ArrayList对象里包含了此变量modCount并且初始化值为0;

  • 通过查看源码,我们能发现在ArrayList类中有操作modCount的方法都是添加元素的相关功能和删除元素的相关功能。由此可以得出结构修改的解释,是指那些改变列表大小的操作,最开始执行的查看和修改的操作,在实质上并没有改变存储学生的数量

Java—— ConcurrentModificationException(并发修改异常)_第5张图片

Java—— ConcurrentModificationException(并发修改异常)_第6张图片 

Java—— ConcurrentModificationException(并发修改异常)_第7张图片Java—— ConcurrentModificationException(并发修改异常)_第8张图片 Java—— ConcurrentModificationException(并发修改异常)_第9张图片

Java—— ConcurrentModificationException(并发修改异常)_第10张图片 

 通过以上源码可以发现:每删除一个元素,modCount的值会自增一次;每添加一个元素,modCount的值也会自增一次,我们每次执行对集合中的元素数量产生变化的操作时,modCount的值就会+1,但是这个操作仅限于增删元素,修改元素值并不会影响modCount的值,再结合API中对此变量的解释,我们可以得出结论: modCount变量代表了对集合元素个数的改变次数

异常分析

上文中已经提到过,当ArrayList对象调用iteroter()方法时,会创建内部类Itr的对象。此时迭代器对象中有两个最关键的成员变量:cursor、expectedModCount

cursor:

       迭代器的工作就是将集合中的元素逐个取出,而cursor就是迭代器中用于指向集合中某个元素的指针,在迭代器迭代的过程中,cursor初始值为0,每次取出一个元素,cursor值会+1,以便下一次能指向下一个元素,直到cursor值等于集合的长度为止,从而达到取出所有元素的目的。expectedModCount:

       expectedModCount在迭代器对象创建时被赋值为modCount,当创建完迭代器对象后,如果我们没有对集合结构进行修改,expectedModCount的值是一直等于modCount的值的,在迭代集合元素的过程中,迭代器通过检查expectedModCount和modCount的值是否相同,以防止出现并发修改,如果这两个值不相等,就会抛出并发修改异常。

三、单线程解决方法

1.使用Iterator中的remove方法

//Iterator中的remove方法
    public static void delete01(){
        Iterator iterator = students.iterator();
        while (iterator.hasNext()) {
            Student next = iterator.next();
            if(next.getName().equals("云九")){
                iterator.remove();
            }
        }
        //遍历查看
        Iterator iterator1 = students.iterator();
        while (iterator1.hasNext()) {
            Student next = iterator1.next();
            System.out.println(next.toString());
        }
    }

运行结果

Java—— ConcurrentModificationException(并发修改异常)_第11张图片

 

原因:

不要和List中的remove混用!迭代器封装的一个自己的remove方法,可以看到这是Iterator接口的默认方法

 

进一步查看Iterator接口的实现类中,ArrayList重写的Iterator接口的remove方法

Java—— ConcurrentModificationException(并发修改异常)_第12张图片

可以发现Iterator接口的remove方法在最后一步多了一个操作,expectedModCount = modCount,也就是说使用迭代器的remove方法时,当modCount发生改变后,expectedModCount也会跟着改变,下一次检查时必然是相等的!所以不会出现并发修改异常!

Java—— ConcurrentModificationException(并发修改异常)_第13张图片

2.使用List接口特有迭代器ListIterator中的方法

//ListIterator
    public static void delete02(){
        ListIterator listIterator = students.listIterator();
        while (listIterator.hasNext()) {
            Student next = listIterator.next();
            if(next.getName().equals("云九")){
                listIterator.remove();
            }
        }
        //查看遍历
        Iterator iterator1 = students.iterator();
        while (iterator1.hasNext()) {
            Student next = iterator1.next();
            System.out.println(next.toString());
        }
    }

运行结果

Java—— ConcurrentModificationException(并发修改异常)_第14张图片

 

原因:

从原码中可以发现ListIterator是Iterator的子接口,查看ArrayList重写的ListIterator接口的remove方法

Java—— ConcurrentModificationException(并发修改异常)_第15张图片

 

同样可以发现ListIterator接口的remove方法在最后一步多了一个操作,expectedModCount = modCount操作

Java—— ConcurrentModificationException(并发修改异常)_第16张图片

Java—— ConcurrentModificationException(并发修改异常)_第17张图片  

仔细对比,可以发现Iterator的remove方法和ListIterator的remove方法是完全一致的!然后仔细对比源码可以发现ArrayList类的remove方法(872行~885行)实质上实现了对Iterator和ListIterator两个接口的remove的重写,也就是说Iterator和ListIterator实质上用的是同一个重写后的remove方法!所以这两种方法避免并发修改异常的原理是完全相同的!

3.使用removeIf()方法解决

//removeIf()
    public static void delete03(){
        students.removeIf(student -> student.getName().equals("云九"));
        //查看遍历
        Iterator iterator1 = students.iterator();
        while (iterator1.hasNext()) {
            Student next = iterator1.next();
            System.out.println(next.toString());
        }
    }

运行结果

Java—— ConcurrentModificationException(并发修改异常)_第18张图片

 

原因:

从源码中可以知道,自JDK1.8后,Collection以及其子类新加入了removeIf方法,作用是按照一定规则过滤集合中的元素

Java—— ConcurrentModificationException(并发修改异常)_第19张图片

 

在ArrayList类中重写的removeif方法中,我们依旧可以看到expectedModCount = modCount的操作

Java—— ConcurrentModificationException(并发修改异常)_第20张图片

 

查看Collection中removeIf的重写方法,可以发现能够使用removeIf方法的类很少,所以removeIf方法的使用具有一定的局限性(1.实现了Collection接口 2.该集合必须重写了Collection接口removeIf方法 3.重写的removeIf方法中必须有expectedModCount = modCount的操作)

Java—— ConcurrentModificationException(并发修改异常)_第21张图片

 

4.普通for循环/间接删除

普通for循环只有在能通过下标获取元素的集合中可用,因为他不涉及迭代器的next方法,不会检查modCount的值。

当在某种特定条件存在的情况下删除一个元素时,可以通过contains()方法;删除多个元素时可以通过新创建一个容器B存放所有需要删除的元素,通过遍历新容器B来删除旧容器A中的值,此时遍历的是A,遍历过程中检查的也是A的modCount值,所以删除B中的元素并不会报并发修改异常。

5.使用Stream流过滤

//使用Stream流过滤
    public static void delete05(){
        List del = students.stream().filter(student -> !student.getName().equals("云九")).collect(Collectors.toList());
        //查看遍历
        Iterator iterator = del.iterator();
        while (iterator.hasNext()) {
            Student next = iterator.next();
            System.out.println(next.toString());
        }
    }

运行结果:

Java—— ConcurrentModificationException(并发修改异常)_第22张图片

 

实质上是将students中名字不等于“云九”的学生复制到新的容器del中,将“云九“在复制的过程中过滤掉了,但是该操作具有极大局限性,Collectors 是Java8加入的操作类,位于 java.util.stream 包下。它会根据不同的规则将元素收集归纳起来,比如最简单常用的是将元素装入Map、Set、List 等可变容器中,也只能装入Map、Set、List 中,所以过滤后得到的新的容器只能是Map、Set或者List,而不是原来的容器类型

将del赋给原容器:

//使用Stream流过滤
    public static void delete05(){
        List del = students.stream().filter(student -> !student.getName().equals("云九")).collect(Collectors.toList());
        //清空原集合
        students.clear();
        //将del添加到原集合中
        students.addAll(del);
        //查看遍历
        Iterator iterator = students.iterator();
        while (iterator.hasNext()) {
            Student next = iterator.next();
            System.out.println(next.toString());
        }
    }

四、多线程解决方法

注意:并发修改异常与线程安全无关,与多线程无关!只与迭代器有关!实质上是只与modCount有关!

在讲解并发修改异常时全程以ArrayList为例,有人可能说是因为ArrayList是非线程安全的,换成线程安全的Vector就没问题了,实际上换成Vector还是会出现这种错误。原因在于,虽然Vector的方法采用了synchronized进行了同步,但是实质上每个线程里面用到的都是不同的iterator,因为遍历的是同一个容器,所以最开始的容器的modCount和不同线程中各自的expectedModCount值都是相等的,但是expectedModCount是每个线程各自私有的(一对多,一个modCount对应多个线程的expectedModCount值)。

假设此时有2个线程,线程1在进行遍历,线程2在进行修改或者删除,那么线程2修改后导致modCount自增了,线程2的expectedModCount没有发生变化,同时线程1的expectedModCount也没有发生变化,此时遍历仍旧会出现并发修改异常的情况。

 public static void main(String[] args) {
        //遍历线程
        Thread thread1 = new Thread(){
            public void run() {
                Iterator iterator = students.iterator();
                while(iterator.hasNext()){
                    Student next = iterator.next();
                    System.out.println(next);
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
        //删除线程
        Thread thread2 = new Thread(){
            public void run() {
                Iterator iterator = students.iterator();
                while(iterator.hasNext()){
                    Student next = iterator.next();
                    if(next.getName().equals("云九")){
                        students.remove(next);
                    }
                }
            }
        }
        thread1.start();
        thread2.start();
    }

运行结果

Java—— ConcurrentModificationException(并发修改异常)_第23张图片

 

假定线程2使用的是迭代器的remove方法,线程1在进行遍历,线程2在进行修改或者删除,那么线程2修改后导致modCount自增了,线程2的expectedModCount在迭代器的remove方法中的expectedModCount = modCount的操作下也自增了,但是线程1的expectedModCount没有发生变化,此时遍历仍旧会出现并发修改异常的情况。

 public static void main(String[] args) {
        //遍历线程
        Thread thread1 = new Thread(){
            public void run() {
                Iterator iterator = students.iterator();
                while(iterator.hasNext()){
                    Student next = iterator.next();
                    System.out.println(next);
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            };
        };
        //删除线程
        Thread thread2 = new Thread(){
            public void run() {
                Iterator iterator = students.iterator();
                while(iterator.hasNext()){
                    Student next = iterator.next();
                    if(next.getName().equals("云九")){
                        //迭代器删除
                         iterator.remove();
                    }
                }
            };
        };
        thread1.start();
        thread2.start();
    }

运行结果

Java—— ConcurrentModificationException(并发修改异常)_第24张图片

 

1.同步iterator迭代器

对整个迭代器进行线程同步操作,当某个线程中的迭代器执行完全部代码时,其他线程中的迭代器才能执行

2.使用并发容器代替

以并发容器CopyOnWriteArrayList为例,CopyOnWriteArrayList是ArrayList的线程安全版本,内部也是通过数组实现,相当于一个副本,每次修改都会拷贝一份新的来修改,修改完了再替换掉原来的

五、扩展

1.神奇的倒数第二个元素

使用迭代器遍历时,使用集合对象的remove方法删除倒数第二个元素,不会报并发修改异常

Itertor中维护了cursor, lastRet,expectedModCount三个成员变量,

//迭代器在ArrayList中的内部实现类
private class Itr implements Iterator {
    int cursor;       // 下一个元素的索引
    int lastRet = -1; // 上一次取出的元素的索引
    int expectedModCount = modCount;  // 记录迭代器被创建后的集合结构改变的次数
    // .....
}
// 判断是否还能继续遍历,当cursor=size时,后续再取值或者删除都会越界
public boolean hasNext() {
    return cursor != size;
}
public E next() {
    //判定expectedModCount与modCount之间是否相等,如果不相等,则抛出异常
    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;    // 加一是为了使cursor变成下一次遍历要取的值的索引
    return (E) elementData[lastRet = i]; // 给lastRet赋值,此时的i已经变成了上一次取出值的索引
}
​
final void checkForComodification() {
    if (modCount != expectedModCount)
        // 当不相等时,跑出异常
        throw new ConcurrentModificationException();
}

当在删除倒数第二个元素的时候,cursor指向最后一个元素的,而此时删掉了倒数第二个元素后,cursor和size()正好相等了,所以hasNext()返回false,遍历结束,这样就成功的删除了倒数第二个元素了。

辅助理解:假设一个装有8个元素的容器,当我们遍历到倒数第二个元素,cursor的值是7,删除完倒数第二个元素的时候,modCount就自增了一次,而容器存储的元素个数也从8变成了7,接着迭代器调用hasNext()方法判断cursor和size是否相同,结果发现相同都是7,所以返回false,while循环就直接终止了,不会再调用next()方法,所以也就不会再检查modCount值,所以不会再报并发修改异常

2.fail-fast 机制与并发修改异常

ConcurrentModificationException异常是由fail-fast机制产生的,java.util包下面的所有的集合类都是快速失败的,“快速失败” 也就是fail-fast,它是Java集合的一种错误检测机制。当多个线程对集合进行结构上的改变的操作时,有 可能会产生fail-fast机制。记住是有可能,而不是一定!

六、相关面试题

  • ConcurrentModificationException异常出现的原因

  • 遍历ArrayList时如何正确移除一个元素

  • Iterator和ListIterator的区别是什么?

    • ListIterator有add()方法,可以向List中添加对象,而Iterator不能

    • ListIterator和Iterator都有hasNext()和next()方法,可以实现按顺序向后遍历,但是ListIterator有hasPrevious()和previous()方法,可以实现逆向(顺序向前)遍历,Iterator就不可以,即Iterator只可以向前遍历,而LIstIterator可以双向遍历

    • ListIterator可以定位当前的索引位置,nextIndex()和previousIndex()可以实现,Iterator没有此功能

    • 都可实现删除对象,但是ListIterator可以实现对象的修改,set()方法可以实现。Iierator仅能遍历,不能修改

  • 通过迭代器fail-fast属性,你明白了什么?

    每次我们尝试获取下一个元素的时候,Iterator fail-fast属性检查当前集合结构里的任何改动。如果发现任何改动,它抛出ConcurrentModificationException。Collection中所有Iterator的实现都是按fail-fast机制来设计的(ConcurrentHashMap和CopyOnWriteArrayList这类并发集合类除外)。

  • fail-fast与fail-safe有什么区别?

    fail-fast:直接在容器上进行遍历,在遍历过程中,一旦发现容器中的数据被修改了,会立刻抛出ConcurrentModificationException异常导致遍历失败。java.util包下的集合类都是快速失败机制的, 常见使用fail-fast方式遍历的容器有HashMap和ArrayList等

    fail-safe:这种遍历基于容器的一个克隆。因此,对容器内容的修改不影响遍历。java.util.concurrent包下的容器都是安全失败的,可以在多线程下并发使用,并发修改。常见的的使用fail-safe方式遍历的容器有ConcerrentHashMap和CopyOnWriteArrayList等

  • 在迭代一个集合的时候,如何避免ConcurrentModificationException?

在遍历一个集合的时候,我们可以使用并发集合类来避免ConcurrentModificationException,比如使用CopyOnWriteArrayList,而不是ArrayList

  • 并发集合类是什么?

    Java1.5并发包(java.util.concurrent)包含线程安全集合类,允许在迭代时修改集合。迭代器被设计为fail-fast的,会抛出ConcurrentModificationException。常见的有CopyOnWriteArrayList、ConcurrentHashMap、CopyOnWriteArraySet

你可能感兴趣的:(java,jvm)