并发修改异常ConcurrentModificationException详解

一、简介

在多线程编程中,相信很多小伙伴都遇到过并发修改异常ConcurrentModificationException,本篇文章我们就来讲解并发修改异常的现象以及分析一下它是如何产生的。

  • 异常产生原因:并发修改异常指的是在并发环境下,当方法检测到对象的并发修改,但不允许这种修改时,抛出该异常。

下面看一个示例:

java 复制代码
public class TestConcurrentModifyException {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");
        list.add("3");
        Iterator<String> iterator = list.iterator();
        while (iterator.hasNext()) {
            String nextElement = iterator.next();
            if (Integer.parseInt(nextElement) < 2) {
                list.add("2");
            }
        }
    }
}

运行此程序,控制台输出,程序出现异常:

java 复制代码
Exception in thread "main" java.util.ConcurrentModificationException
	at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:901)
	at java.util.ArrayList$Itr.next(ArrayList.java:851)
	at com.wsh.springboot.helloworld.TestConcurrentModifyException.main(TestConcurrentModifyException.java:15)

可见,控制台显示的ConcurrentModificationException,即并发修改异常。下面我们就以ArrayList集合中出现的并发修改异常为例来分析异常产生的原因。

二、异常原因分析

通过上面的异常信息可见异常抛出在ArrayList类中的checkForComodification()方法中。下面是checkForComodification方法的源码:

java 复制代码
final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

checkForComodification()方法实际上就是当modCount 变量值不等于expectedModCount变量值时,就会触发此异常。

那么modCount 和expectedModCount分别代表什么呢?

  • modCount :AbstractList类中的一个成员变量,由于ArrayList继承自AbstractList,所以ArrayList中的modCount变量也继承过来了。
java 复制代码
protected transient int modCount = 0;

简单理解,modCount 就是ArrayList中集合结构的修改次数【实际修改次数】,指的是新增、删除(不包括修改)操作。

  • expectedModCount:是ArrayList中内部类Itr的一个成员变量,当我们调用iteroter()获取迭代器方法时,会创建内部类Itr的对象,并给其成员变量expectedModCount赋值为ArrayList对象成员变量的值modCount【预期修改次数】。
java 复制代码
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
    //将ArrayList对象成员变量的值modCount赋值给expectedModCount成员变量
    int expectedModCount = modCount;
    //....
    }

经过上面的分析,我们知道了当我们获取到集合的迭代器之后,Itr对象创建成功后,expectedModCount 的值就确定了,就是modCount的值,在迭代期间不允许改变了。要了解它两为啥不相等, 我们就需要观察ArrayList集合的什么操作会导致modCount变量发生变化,从而导致modCount != expectedModCount ,从而发生并发修改异常。

查看ArrayList的源码可知,modCount 初始值为0, 每当集合中添加一个元素或者删除一个元素时,modCount变量的值都会加一,表示集合中结构修改次数多了一次。下面简单看下ArrayList的add()方法和remove()方法。

  • add():每添加一个元素,modCount的值也会自增一次
java 复制代码
public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}
 
private void ensureCapacityInternal(int minCapacity) {
    //第一次添加元素
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        //默认容量DEFAULT_CAPACITY为10
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }
 
    ensureExplicitCapacity(minCapacity);
}
 
private void ensureExplicitCapacity(int minCapacity) {
    //集合结构修改次数加一
    modCount++;
 
    // overflow-conscious code
    if (minCapacity - elementData.length > 0)
        //扩容方法,扩容后是原容量的1.5倍
        //扩容前:数组长度10  扩容后:数组长度变为10 + (10 / 2) = 15
        grow(minCapacity);
}
  • remove():每删除一个元素,modCount的值会自增一次
java 复制代码
public E remove(int index) {
    //检查索引是否越界
    rangeCheck(index);
    
    //集合结构修改次数加一
    modCount++;
    //数组中对应索引的值
    E oldValue = elementData(index);
 
    //计算需要移动元素的位数
    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
    //返回原先索引对应的值
    return oldValue;
}

**注意!注意!注意!ArrayList中的修改方法set()并不会导致modCount变量发生变化,**set()方法源码如下:

java 复制代码
public E set(int index, E element) {
    rangeCheck(index);
 
    E oldValue = elementData(index);
    elementData[index] = element;
    return oldValue;
}

三、异常原因追踪

下面我们就Debug调试一下刚刚那个例子,详解了解一下,并发修改异常时怎么产生的。

当我们调用iterator()获取迭代器时,实际上底层创建了一个Itr内部类对象

java 复制代码
public Iterator<E> iterator() {
    return new Itr();
}

初始化Itr的成员变量:可以看到,expectedModCount = 3,表示预期修改次数为3,如果在迭代过程中,发现modCount不等于3了,那么就会触发并发修改异常。

下面简单说明一下Itr的源码:

java 复制代码
private class Itr implements Iterator<E> {
    //cursor初始值为0,每次取出一个元素,cursor值会+1,以便下一次能指向下一个元素,直到cursor值等于集合的长度为止
    int cursor;       // index of next element to return
    int lastRet = -1; // index of last element returned; -1 if no such
    //初始化预期修改次数为实际修改次数modCount,即上图中的3
    int expectedModCount = modCount;
 
    //判断是否还有下一个元素:通过比较游标cursor是否等于数组的长度
    //因为集合中最后一个元素的索引为size-1,只要cursor值不等于size,证明还有下一个元素,此时hasNext方法返回true,
   //如果cursor值与size相等,那么证明已经迭代到最后一个元素,返回false
    public boolean hasNext() {
        return cursor != size;
    }
 
    //拿出集合中的下一个元素
    @SuppressWarnings("unchecked")
    public E next() {
        //并发修改异常出现根源
        //ConcurrentModificationException异常就是从这抛出的
        //当迭代器通过next()方法返回元素之前都会检查集合中的modCount和最初赋值给迭代器的expectedModCount是否相等,如果不等,则抛出并发修改异常
        checkForComodification();
        int i = cursor;
        //判断,如果大于集合的长度,说明没有元素了。
        if (i >= size)
            throw new NoSuchElementException();
        //将集合存储数据数组的地址赋值给局部变量elementData     
        Object[] elementData = ArrayList.this.elementData;
        if (i >= elementData.length)
            throw new ConcurrentModificationException();
        //每次获取完下一个元素后,游标向后移动一位    
        cursor = i + 1;
        //返回当前游标对应的元素
        return (E) elementData[lastRet = i];
    }
 
    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();
        }
    }
    
    final void checkForComodification() {
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }
}

继续Debug,我们记录一下几次hasNext()/next()方法时,其中几个重要变量值的变化过程。

java 复制代码
第一次调用hasNext(): cursor = 0    size = 3
第一次调用iterator.next():
    第一次调用checkForComodification():modCount = 3  expectedModCount = 3
由于 modCount = expectedModCount ,不会发生并发修改异常。并且返回当前游标对应的值,即返回1.
由于满足Integer.parseInt(nextElement) < 2,所以会执行list.add("2")方法,之前已经了解到,add()方法会
修改modCount的值 + 1· 所以此时modCount的值变为4了.
第一次next()方法调用完,cursor游标的值会加一,所以cursor = 1. 
 
===============================================================================================================
第二次调用hasNext(): cursor = 1  size = 4
第二次调用iterator.next():
    第二次调用checkForComodification():modCount = 4  expectedModCount = 3
由于 modCount != expectedModCount ,此时会发生并发修改异常。
 
以上就是ConcurrentModificationException一场产生的简单解析过程。  

下图是发生并发修改异常时checkForComodification()方法的执行过程,注意modCount和expectedModCount 的值:

四、并发修改异常的特殊情况

示例:已知集合中有三个元素:"chinese"、"math"、"english",使用迭代器进行遍历, 判断集合中存在"english",如果存在则删除。

java 复制代码
public class TestConcurrentModifyException {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("chinese");
        list.add("math");
        list.add("english");
        Iterator<String> iterator = list.iterator();
        while (iterator.hasNext()) {
            String nextElement = iterator.next();
            if ("english".equals(nextElement)) {
                //使用ArrayList的boolean remove(Object o)方法进行删除
                list.remove("english");
            }
        }
    }
}

程序运行结果:

java 复制代码
Exception in thread "main" java.util.ConcurrentModificationException
	at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:901)
	at java.util.ArrayList$Itr.next(ArrayList.java:851)
	at com.wsh.springboot.helloworld.TestConcurrentModifyException.main(TestConcurrentModifyException.java:19)

通过上面的分析,由于往集合中加入了三个元素,所以modCount实际修改次数的值为3,当我们调用iterator()获取迭代器的时候,初始化expectedModCount的值也为3。下面我们一起看一下ArrayList类中的根据元素删除方法的源码。

remove(Object o)方法源码:

java 复制代码
public boolean remove(Object o) {
    //判断需要删除的元素是否为null
    if (o == null) {
        for (int index = 0; index < size; index++)
            if (elementData[index] == null) {
                fastRemove(index);
                return true;
            }
    } else {
        //不为null,遍历集合,使用equals进行比较是否相等
        for (int index = 0; index < size; index++)
            if (o.equals(elementData[index])) {
                fastRemove(index);
                return true;
            }
    }
    return false;
}
 
private void fastRemove(int index) {
    //删除元素时,实际修改次数会自增1
    //此时: modCount实际修改次数为4,但是预期修改次数还是获取迭代器时候的3,两者已经不一致了。
    modCount++;
    //计算集合需要移动元素的个数
    int numMoved = size - index - 1;
    if (numMoved > 0)
        //数组拷贝
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    //将删除元素置为null,利于垃圾回收
    elementData[--size] = null; // clear to let GC do its work
}

我们分析一下程序的执行过程,查看并发修改异常是怎么产生的。当我们执行到下面一行语句之后,集合的size会减1,所以此时size = 2.

java 复制代码
list.remove("english");

那么这时候再次执行下面的判断

java 复制代码
while (iterator.hasNext()) {

此时cursor的值是3,但是size的值是2,两者不相等,所以hasNext()方法返回true,意味着集合中还有元素,所以还会执行一次next()方法,此时执行checkForComodification()方法,判断modCount是否等于expectedModCount,(expectedModCount=3, modCount=4),两者不相等,所以这就抛出了并发修改异常。

小结论:

  1. 集合每次调用add方法时,实际修改次数的值modCount都会自增1;

  2. 在获取迭代器的时候,集合只会执行一次将实际修改集合的次数modCount的值赋值给预期修改的次数变量expectedModCount;

  3. 集合在删除元素的时候,也会针对实际修改次数modCount的变量进行自增操作;

下面再来看一个并发修改异常的特殊情况,观察下面的程序:

示例:已知集合中有三个元素:"chinese"、"math"、"english",使用迭代器进行遍历,判断集合中存在"math",如果存在则删除。

java 复制代码
public class TestConcurrentModifyException {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("chinese");
        list.add("math");
        list.add("english");
        Iterator<String> iterator = list.iterator();
        while (iterator.hasNext()) {
            String nextElement = iterator.next();
            if ("math".equals(nextElement)) {
                //使用ArrayList的boolean remove(Object o)方法进行删除
                list.remove("math");
            }
        }
        System.out.println(list);
    }
}

我们可以看到,这个示例跟上面一个实例非常相似,唯一不同的是这次删除的元素是集合中的倒数第二个元素。

程序运行结果:

java 复制代码
[chinese, english]

我们看到,这里并没有发生并发修改异常,很神奇,而且成功删除"math"这个元素,这是为什么呢?上面一个示例明明说了会发生并发修改异常。下面我们还是分析一下其中的特殊原因:

java 复制代码
第一次调用hasNext(): cursor = 0    size = 3, hasNext()返回true
第一次调用iterator.next():
    第一次调用checkForComodification():modCount = 3  expectedModCount = 3
由于 modCount = expectedModCount ,不会发生并发修改异常。
第一次next()方法调用完,cursor游标的值会加一,所以cursor = 1. 
 
===============================================================================================================
第二次调用hasNext(): cursor = 1  size = 3, hasNext()返回true
第二次调用iterator.next():
    第二次调用checkForComodification():modCount = 3  expectedModCount = 3
由于 modCount = expectedModCount ,不会发生并发修改异常。
第二次next()方法调用完,cursor游标的值会加一,所以cursor = 2. 
由于上面的示例中,"math"元素刚好在第二个,所以这时候"math".equals(nextElement)会返回true,
所以会执行集合的删除元素方法,size会减一,实际修改次数modCount会加一,所以size = 2  modCount = 4
 
ps:这里注意cursor游标的值也是2,size的值也是2,
 
===============================================================================================================
第三次调用hasNext(): cursor = 2  size = 2, 两者相等,所以hasNext()返回false,while循环结束,意味着不会调用next()方法,
不会执行调用checkForComodification()方法,那么肯定就不会发生并发修改异常。

小结论:

  1. 当要删除的元素在集合中的倒数第二个元素的时候,删除元素不会产生并发修改异常。
  2. 原因:因为在调用hasNext()方法的时候,cursor = size是相等的,hasNext()方法会返回false, 所以不会执行next()方法,也就不会调用checkForComodification()方法,就不会发生并发修改异常。

四、如何避免并发修改异常?

如何避免并发修改异常还有它的特殊情况呢,其实Iterator迭代器里面已经提供了remove(),用于在迭代过程对集合结构进行修改,使用iterator.remove()不会产生并发修改异常,为什么迭代器的删除方法不会产生异常呢,我们得去看看Itr内部类的remove()源码:

java 复制代码
//迭代器自带的删除方法
public void remove() {
    if (lastRet < 0)
        throw new IllegalStateException();
    //校验是否产生并发修改异常    
    checkForComodification();
 
    try {
          //真正删除元素的方法还是调用的ArrayList的删除方法
         //根据索引进行删除
         ArrayList.this.remove(lastRet);
        
        cursor = lastRet;
        lastRet = -1;
        //每次删除完成后,会重新将expectedModCount重新赋值,值就是实际修改次数modCount的值
        //这就保证了,实际修改次数modCount一定会等于预期修改次数expectedModCount ,所以不会产生并发修改异常.
        expectedModCount = modCount;
    } catch (IndexOutOfBoundsException ex) {
        throw new ConcurrentModificationException();
    }
}

小结论:

  1. 迭代器调用remove()方法删除元素,底层还是调用的集合的删除元素的方法;
  2. 在调用remove()方法后,都会将modCount的值赋值给expectedModCount,保证了它两的值永远都是相等的,所以也就不会产生并发修改异常;

五、总结

以上通过几个示例讲解了并发修改异常的现象,以及分析了并发修改异常是如何产生的,在实际工作中,如果需要使用到删除集合中元素,那么我们不要使用集合自带的删除方法,我们应该使用iterator迭代器给我们提供的删除方法,这样可以很大程序避免程序发生并发修改异常ConcurrentModificationException。

相关推荐
FF在路上28 分钟前
Knife4j调试实体类传参扁平化模式修改:default-flat-param-object: true
java·开发语言
真的很上进35 分钟前
如何借助 Babel+TS+ESLint 构建现代 JS 工程环境?
java·前端·javascript·css·react.js·vue·html
众拾达人1 小时前
Android自动化测试实战 Java篇 主流工具 框架 脚本
android·java·开发语言
皓木.1 小时前
Mybatis-Plus
java·开发语言
不良人天码星1 小时前
lombok插件不生效
java·开发语言·intellij-idea
测试老哥1 小时前
外包干了两年,技术退步明显。。。。
自动化测试·软件测试·python·功能测试·测试工具·面试·职场和发展
守护者1701 小时前
JAVA学习-练习试用Java实现“使用Arrays.toString方法将数组转换为字符串并打印出来”
java·学习
源码哥_博纳软云2 小时前
JAVA同城服务场馆门店预约系统支持H5小程序APP源码
java·开发语言·微信小程序·小程序·微信公众平台
禾高网络2 小时前
租赁小程序成品|租赁系统搭建核心功能
java·人工智能·小程序
学会沉淀。2 小时前
Docker学习
java·开发语言·学习