【集合类】 1 java.util.ConcurrentModificationException异常详解&ArrayList&CopyOnWriteArrayList原理探究

环境:JDK 1.8.0_111

概述

在Java开发过程中,使用iterator或for遍历集合的同时对集合进行修改就会出现java.util.ConcurrentModificationException异常,本文就以ArrayList为例去理解和解决这种异常,一般抛出异常的场景分为两类,一是单线程场景,二是多线程场景,尤其是第二个场景不容易察觉,不幸的是小编就中招了。

一、单线程情况下问题分析及解决方案

1.1 问题复现

先上一段会抛异常的代码,iterator方式迭代的过程中,调用 list.remove():

import java.util.ArrayList;
import java.util.Iterator;

public class ExceptionTest1 {
    public void test()  {
        ArrayList<Integer> arrayList = new ArrayList<Integer>(); //构建数组,并填充20个元素
        for (int i = 0; i < 20; i++) {
            arrayList.add(Integer.valueOf(i));
        }

        Iterator<Integer> iterator = arrayList.iterator();
        while (iterator.hasNext()) {      //使用iterator迭代
            Integer integer = iterator.next();      //报错的地方  
            if (integer.intValue() == 5) {
                arrayList.remove(integer);  //删除操作,注意调用的是数组的remove,不是iterator的remove
            }
        }
    }
    public static void main(String[] args) {
        ExceptionTest1 test = new ExceptionTest1();
        test.test();
    }
}

上面代码报错的地方不是remove(),而是next()

上面的代码是很常见的方式,即循环体中操作数据,新增或删除都会触发同样的异常,在for循环体中也会复现:

 for( Integer integer  :arrayList){ 
   if(integer.intValue() == 5){   //报错的地方 
          arrayList.remove(integer);  //删除操作
      }
  }

for(元素类型t 元素变量x : 遍历对象obj)语法底层调用foreach(),和for(int i =0;i<10;i++)不同

1.2 问题原因分析

使用Iterator()遍历ArrayList, 抛出异常的是iterator.next()。看下Iterator next()方法实现源码

//java.util.ArrayList.Itr

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)  //`原因所在,该方法会判断modCount 是否等于 expectedModCount`
         throw new ConcurrentModificationException();
 }

在next()方法中首先调用了checkForComodification方法,该方法会判断modCount是否等于expectedModCount,不等于就会抛出java.util.ConcurrentModificationExcepiton异常。

我们接下来跟踪看一下modCountexpectedModCount的赋值和修改。

  • modCount 是ArrayList变量,共享的,即使是多线的,访问的都是同一个值
  • expectedModCount 是迭代器产生的一个私有变量,多个迭代器会产生多个

modCount是ArrayList的一个属性,继承自抽象类AbstractList,用于表示ArrayList对象被修改次数

 protected transient int modCount = 0;

整个ArrayList中修改modCount的方法比较多,有addremoveclearensureCapacityInternal等,凡是设计到ArrayList对象修改的都会自增modCount属性。

在创建Iterator的时候会将modCount赋值给expectedModCount,expectedModCount是迭代器产生的一个私有的变量,在遍历ArrayList过程中,没有其他地方可以设置expectedModCount了,因此遍历过程中expectedModCount一直保持初始值20(调用add方法添加了20个元素,修改了20次)

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

在执行next方法时,遇到modCount != expectedModCount方法,导致抛出异常java.util.ConcurrentModificationException

明白了抛出异常的过程,但是为什么要这么做呢?很明显这么做是为了阻止程序员在不允许修改的时候修改对象,起到保护作用,避免出现未知异常。

Iterator 是工作在一个独立的线程中,并且拥有一个 mutex 锁。 Iterator 被创建之后会建立一个指向原来对象的单链索引表,当原来的对象数量发生变化时,这个索引表的内容不会同步改变。

当索引指针往后移动的时候就找不到要迭代的对象,所以按照 fail-fast 原则 Iterator 会马上抛出 java.util.ConcurrentModificationException 异常。
所以 Iterator 在工作的时候是不允许被迭代的对象被改变的。但你可以使用 Iterator 本身的方法 remove() 来删除对象, Iterator.remove() 方法会在删除当前迭代对象的同时维护索引的一致性。

有关fail-fast更详细信息请参考《java中快速失败(fail-fast)和安全失败(fail-safe)的区别是什么?》

再来分析下第二种for循环抛异常的原因:

//java.util.ArrayList

public void forEach(Consumer<? super E> action) {
    Objects.requireNonNull(action);
    final int expectedModCount = modCount;
    @SuppressWarnings("unchecked")
    final E[] elementData = (E[]) this.elementData;
    final int size = this.size;
    for (int i=0; modCount == expectedModCount && i < size; i++) {
        action.accept(elementData[i]);
    }
    if (modCount != expectedModCount) {
        throw new ConcurrentModificationException();
    }
}

在for循环中一开始也是对expectedModCount采用modCount进行赋值。在进行for循环时每次都会有判定条件modCount == expectedModCount,当执行完arrayList.remove(integer)之后,该判定条件返回false退出循环,然后执行if语句,结果同样抛出java.util.ConcurrentModificationException异常。

这两种复现方法实际上都是同一个原因导致的

1.3 问题解决方案

上述的两种复现方法都是在单线程运行的,先来说明单线程中的解决方案:

public void test2() {
        ArrayList<Integer> arrayList = new ArrayList<>();
        for (int i = 0; i < 20; i++) {
            arrayList.add(Integer.valueOf(i));
        }

        Iterator<Integer> iterator = arrayList.iterator();
        while (iterator.hasNext()) {
            Integer integer = iterator.next();
            if (integer.intValue() == 5) {
                iterator.remove();  //调用iterator.remove()
            }
        }
    }

这种解决方案最核心的就是调用iterator.remove()方法。我们看看该方法源码为什么这个方法能避免抛出异常

//java.util.ArrayList.Itr

public void remove() {
       if (lastRet < 0)
           throw new IllegalStateException();
       checkForComodification();

       try {
           ArrayList.this.remove(lastRet);
           cursor = lastRet;
           lastRet = -1;
           expectedModCount = modCount;   //`核心,重置了expectedModCount值`
       } catch (IndexOutOfBoundsException ex) {
           throw new ConcurrentModificationException();
       }
   }

iterator.remove()方法中,同样调用了ArrayList自身的remove方法,但是调用完之后并非就return了,而是expectedModCount = modCount重置了expectedModCount值,使二者的值继续保持相等。

针对forEach循环并没有修复方案,因此在遍历过程中同时需要修改ArrayList对象,则需要采用iterator遍历。

上面提出的解决方案调用的是iterator.remove()方法,如果不仅仅是想调用remove方法移除元素,还想增加元素,或者替换元素,是否可以呢?浏览Iterator源码可以发现这是不行的,Iterator只提供了remove方法。

但是ArrayList实现了ListIterator接口,ListIterator类继承了Iter,这些操作都是可以实现的,使用示例如下:

public void test3() {
        ArrayList<Integer> arrayList = new ArrayList<>();
        for (int i = 0; i < 20; i++) {
            arrayList.add(Integer.valueOf(i));
        }

        ListIterator<Integer> iterator = arrayList.listIterator(); //`注意此处是listIterator(),不是iterator()`
        while (iterator.hasNext()) {
            Integer integer = iterator.next();
            if (integer.intValue() == 5) {
                iterator.set(Integer.valueOf(6));
                iterator.remove();
                iterator.add(integer);
            }
        }
    }

iterator()是集合类接口定义的方法,而listIterator()是数组定义的方法,详细区别可以参考ArrayList类的Iterator()和ListIterator()的区别是什么

二、 多线程情况下的问题分析及解决方案

2.1 问题复现

启动2个线程,main主线程循序数组并打印,新起的thread负责在main线程运行期间触发一次删除操作,

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class ScheduleExecutorServiceTest {
    public static void main(String[] args) {
        
        //构建数组,有20个数据
       final List list =new ArrayList();
        for(int i=0;i<20;i++){
            list.add(i);
        }
       //启动一个线程,3s后删除下标20的数据
        Thread thread = new Thread( new Runnable() {
            public void run() {
                try {
                    Thread.sleep(3000);
                    list.remove(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
   
        thread.start();
        //主循环任务,仅打印数据,间隔1s
        Iterator it =  list.iterator();
        while (it.hasNext()){
            try { 
                Object o = it.next();   //报错
                System.out.println(o);
                Thread.sleep(1000);
          } catch (InterruptedException e) {
              e.printStackTrace();
          }
        }  
    }   
}

我们看下执行结果:

0
1
2
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 ScheduleExecutor.ScheduleExecutorServiceTest.main(ScheduleExecutorServiceTest.java:32)

2.2 问题分析

从上面代码执行结果可以看出,thread在3s删除一条数据后,main线程已经遍历完3条数据,正准备遍历第4个元素,next的时候抛出异常了。我们从时间点分析一下抛异常的原因:

时间点main线程modCountmain线程expectedModCount
remove之前2020
remove之后2120
很明显,main线程的modCount 被修改了,而expectedModCount 没变导致 出错

2.3 多线程下的解决方案

2.3.1 方案一:加同步锁

iterator遍历过程加同步锁,锁住整个arrayList

 //启动一个线程,3s后删除下标20的数据
   Thread thread = new Thread( new Runnable() {
       public void run() {
           try {
               Thread.sleep(3000);
               //`此处必须也加锁`
               synchronized (list) {
                   
                   list.remove(10);
               }
           } catch (InterruptedException e) {
               e.printStackTrace();
           }
       }
   });

   thread.start();
   //`加锁`
   synchronized (list) {
       Iterator it =  list.iterator();
       while (it.hasNext()){
           try { 
               Object o = it.next();
               System.out.println(o);
               Thread.sleep(1000);
         } catch (InterruptedException e) {
             e.printStackTrace();
         }
       }
       
   }

虽然通过加锁解决了报错信息,但是主线程加锁导致子线程的删除操作一直阻塞,因为删除操作必须在main线程释放锁之后才能完成

上面加锁代码可以稍微优化一下,考虑提升删除操作的效率,给主线程加锁,保证主线程先获取一份拷贝。

 //循环任务,仅打印数据,间隔1s
 Iterator it ;
  synchronized (list) {   //仍然需要加锁
  //`弱拷贝了一份`
  final List list2= new ArrayList(list);
   it =  list2.iterator();
  }
  while (it.hasNext()){
        try { 
            Object o = it.next();
            System.out.println(o);
            Thread.sleep(1000);
      } catch (InterruptedException e) {
          e.printStackTrace();
      }
    }
      
  }

上面优化方案只是弱拷贝了一份数组出来,由于main循环体在新对象上,因此和thead线程各自维护自身的modCount,thead线程的修改操作不影响main线程。

2.3.2 方案二:使用CopyOnWriteArrayList

2个线程之间的关系分为 写写 、读写、读读,读读之间一般不存在线程问题,重点在于 写写 、读写, CopyOnWriteArrayList 通过写操作新建数据方式,解决了读写的问题,通过锁实现了写写之间的问题。

使用CopyOnWriteArrayList解决读写冲突问题,那么我们通过源码来分析,读写方法为何不会冲突。

get方法:

 public E get(int index) {
      return get(getArray(), index);
  }
  @SuppressWarnings("unchecked")
 private E get(Object[] a, int index) {    //很简单,没有加锁
     return (E) a[index];    
 }

整个读的过程没有添加任何锁,就是普通的数组获取。

2、add方法

private transient volatile Object[] array;  //array内部维护一个数组

public boolean add(E e) {
  final ReentrantLock lock = this.lock;
  lock.lock();
  try {
      Object[] elements = getArray();
      int len = elements.length;
      //`数组复制了一份,最后setArray回去,这是不干扰其他线程读操作的根本原因`
      Object[] newElements = Arrays.copyOf(elements, len + 1);  
      newElements[len] = e;
      setArray(newElements);   //调用setArray,重置数组
      return true;
  } finally {
      lock.unlock();
  }
}

final void setArray(Object[] a) {
        array = a;    //给数组重新赋值
    }

写操作添加了一个锁ReentrantLock,保证了写操作线程安全,读写分离,假设当前数组是A,写操作时复制出一个新的数组B,插入、修改或者移除操作均发生在B上,完成后将新数组赋值给array,期间用户读取A,循环操作也是对A的操作,A和B是两个不同对象,因此读写是分离的,for循环也能正确运行。

CopyOnWriteArrayList实现了读写分离,但是写写之间还是需要枷锁的,避免线程安全问题,例如2个线程同时写,岂不产生覆盖现象吗?

下面我们来验证下读写分离:

我们建了2个线程,分别为thread1 和 thread2:

  • thread2
    thread2内会循环打印2次数组数据,第一次循环打印的过程中发生一个删除操作,通过查看第一次打印内容发现,虽然循环体发生了删除操作,但是仍正常工作,并且后续的元素都能读取到,与前面的例子对比,明显解决了报错问题;thread2的第二次打印内容显示此时读到的数组是新数组,不包含已经被删除的元素。
  • thread1
    thread1比thead2先读到数组并打印第一个元素,随后sleep,等结束后,再打印其余元素,在sleep过程中,原始数组中值为3的元素被删除。输出结果中仍然包含3,说明删除操作对读取无影响。
import java.util.List;
import java.util.ListIterator;
import java.util.concurrent.CopyOnWriteArrayList;

public class Test2 {
    public static void main(String[] args) {
        final List<Integer> list = new CopyOnWriteArrayList<Integer>();
        for (int i = 0; i < 6; i++) {
            list.add(Integer.valueOf(i));
        }
        // 子线程1
        Thread thread1 = new Thread(new Runnable() {
            public void run() {
                ListIterator<Integer> iterator = list.listIterator();
                while (iterator.hasNext()) { // 使用iterator循环打印
                    System.out.println("thread1 " + iterator.next().intValue());
                    try {
                        Thread.sleep(2000);   //sleep 2s,确保顺序,先让thread2执行删除元素的操作
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        });
        // 子线程2
        Thread thread2 = new Thread(new Runnable() {
             try {
                    Thread.sleep(1000);     //sleep 1s,确保thread1先读到数组
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            public void run() {
                for (Integer integer : list) {
                    System.out.println("thread2 " + integer.intValue());
                    if (integer.intValue() == 3) { //` for循环删除元素值为3的元素,注意,很重要,下文会讲到不能在iterator中执行remove`
                        list.remove(integer);
                    }
                }
                for (Integer integer : list) { // 使用for循环打印,和thread1有所不同
                    System.out.println("thread2 again " + integer.intValue());
                }
            }
        });
        thread1.start();
        thread2.start();
    }

}

执行结果:

thread1 0   //thread1先读取数组,并sleep
thread2 0
thread2 1
thread2 2
thread2 3    //未删除前,线程2第一次打印`包含3`
thread2 4
thread2 5
thread2 again 0
thread2 again 1
thread2 again 2   //删除后,线程2第二次打印`不包含3`
thread2 again 4
thread2 again 5
thread1 1
thread1 2
thread1 3    //虽然发生了删除,thead1仍然打印出3
thread1 4
thread1 5

我们先分析thread2的输出结果,第一次遍历输出 3 ,情理之中;第一次遍历后删除掉了一个元素,第二次遍历输出不包含 3,符合我们的预期。

再来看下thread1的输出结果,thread1 仍然输出了3
果然是读写分离,互不影响。

这是什么原因保证神奇的效果呢,我们看下源码:

private transient volatile Object[] array;  //内部持有一个数组对象

CopyOnWriteArrayList本质上是对array数组的一个封装,一旦CopyOnWriteArrayList对象发生任何的修改都会new一个新的Object[]数组newElement,在newElement数组上执行修改操作,修改完成后将newElement赋值给array数组(array=newElement)。

因为array是volatile的,因此它的修改对所有线程都可见。

了解了CopyOnWriteArrayList的实现思路之后,我们再来分析上面代码为什么会出现那样的输出结果。先来看下thread1和thread2中用到的两种遍历方式的源码。

时间点CopyOnWriteArrayList的arraythread1 遍历Object数组thread2 第一次遍历Object数组thread2 第二次遍历Object数组
thread2 调用remove方法前A (初始数组)AA/
thread2 调用remove方法之后B (setArray(newElements)赋值新产生的数组)A/B

有了这个时间节点表就很清楚了,thread1和thread2 启动的时候都会将A数组初始化给自己的临时变量,之后遍历的也都是这个A数组,而不管CopyOnWriteArrayList中的array发生了什么变化。因此也就解释了thread1在thread2 remove掉一个元素之后为什么还会输出3了。在thread2中,第二次遍历初始化数组变成了当前的array,也就是修改后的B,因此不会有3这个元素了。

执行结果来看,CopyOnWriteArrayList确实能解决一边遍历一边修改并且还不会抛异常,但是这也是有代价的:

  • 不能保证数据的实时一致性,thread2对array数组的修改thread1并不能被动感知到

  • 内存占用问题 ,每次修改都需要重新new一个数组,并且将array数组数据拷贝到new出来的数组中,效率会大幅下降

CopyOnWriteArrayList注意事项

注意:CopyOnWriteArrayList中的ListIterator实现是不支持removeaddset操作的,一旦调用就会抛出UnsupportedOperationException异常,就是说不能在for循环中执行上述操作(只要不在循环体中执行都可以),这是一个RuntimeException,可以参见下面的Test3;

public class CopyOnWriteArrayList {
    public Iterator<E> iterator() {    //间接继承自java.lang.Iterable接口
        return new COWIterator<E>(getArray(), 0);
    }
	public ListIterator<E> listIterator() {   //继承自java.util.List接口
	        return new COWIterator<E>(getArray(), 0);
	    }
	
	//内部类    
	static final class COWIterator<E> implements ListIterator<E> {
	    /** Snapshot of the array */
	    private final Object[] snapshot;
	    public void remove() {
	        // `已经不支持Iterator remove操作了`
	        throw new UnsupportedOperationException();
	    }

看个调用Iterator.remove报错的例子:

package mychild;

import java.util.List;
import java.util.ListIterator;
import java.util.concurrent.CopyOnWriteArrayList;

public class Test3 {
    public static void main(String[] args) {
        final List<Integer> list = new CopyOnWriteArrayList<Integer>();
        for (int i = 0; i < 6; i++) {
            list.add(Integer.valueOf(i));
        }

        ListIterator<Integer> iterator = list.listIterator();
        while (iterator.hasNext()) {
            Integer value = iterator.next();
            System.out.println(value);
            if (value == 3) {
                iterator.remove();// `使用iterator.remove删除,报错`
            }
        }

    }
}

为什么不能删除呢?

因为读写分离的机制,假设当前数组是A,写操作时复制出一个新的数组B,插入、修改或者移除操作均发生在B上,完成后将新数组赋值给array,期间用户读取A,循环操作也是对A的操作,A此时已经是个快照了,你即使删除了快照,也只是删除了快照里的对象,不影响array,因此避免出现删除失效的情况,直接禁止调用remove、add和set。

那么想删除怎么办?

只能用list.remove()形式删除

三、汇总

  1. ArrayList是非线程安全的
    原因是未加锁

  2. ArrayList在迭代期间,可能引起ConcurrentModificationException异常
    原因是如果数组发生删除操作,Iterator迭代器在迭代期间会进行modCount != expectedModCount比较,导致抛出异常java.util.ConcurrentModificationException,作用是提醒用户发生了数据不一致的情况,请用户捕获处理

  3. 可以使用CopyOnWriteArrayList替代ArrayList
    作用是正在迭代的数组不会产生异常ConcurrentModificationException,原因是读操作是个备份机制,别的操作不影响当前的快照;因为快照机制,产生了弱一致性问题,即数组发生变化,不会立即体现在快照数组中

  4. CopyOnWriteArrayList也有缺陷,不能在迭代期间调用remove,add操作
    原因是读操作是对快照进行的,如果在迭代期间调用remove(),只会删除快照里面的数据,而不会影响原数组,故而直接禁止调用相关的方法。

  5. 由于采用复制原理,CopyOnWriteArrayList适用于读多写少的情况,否则大量的复制操作,会导致频繁的gc

四 remove总结

4.1 ArrayList

ArrayList 的单线程 迭代操作期间,完成remove():

iterator 迭代中
list.remove(i)不支持,报ConcurrentModificationException,因为 list.remove(i) 修改了modCount,未修改expectedModCount, modCount != expectedModCount检查未通过
iterator.remove()支持 , 因为 iterator.remove() 修改了modCount,重置了expectedModCount, modCount != expectedModCount检查通过

多线程下,一个线程迭代,一个线程执行remove():

线程1 iterator 迭代中
线程2执行 list.remove(i)线程1 报ConcurrentModificationException,因为线程2 list.remove(i) 修改了modCount共享变量,未修改线程1中的私有变量expectedModCount, modCount != expectedModCount检查未通过;

线程2不会报错
线程2 也在迭代,并且执行 iterator.remove(i)线程1 报ConcurrentModificationException,因为线程2 iterator.remove(i) 会修改modCount 共享变量的值,虽然重置了自己迭代中的expectedModCount,但让线程1中未修改expectedModCount, modCount != expectedModCount检查未通过

线程2 不会报错

4.2 CopyOnWriteArrayList

不管是单线程还是多线程,迭代器中不允许调用 iterator.remove(i) ,只能通过 list.remove(i) 进行删除。

原因是 迭起器等价于备份了一份数据的快照,在快照中执行删除操作,是无意义的。

并且 2个线程之间的关系分为 写写 、读写、读读,读读之间一般不存在线程问题,重点在于 写写 、读写场景, CopyOnWriteArrayList 通过写操作新建数据方式,解决了读写的问题,通过锁实现了写写之间的问题。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值