环境:JDK 1.8.0_111
在Java开发过程中,使用iterator或for遍历集合的同时对集合进行修改就会出现java.util.ConcurrentModificationException异常,本文就以ArrayList为例去理解和解决这种异常,一般抛出异常的场景分为两类,一是单线程场景,二是多线程场景,尤其是第二个场景不容易察觉,不幸的是小编就中招了。
一、单线程情况下问题分析及解决方案
1.1 问题复现
先上一段抛异常的代码。
iterator方式:
import java.util.ArrayList;
import java.util.Iterator;
public class ExceptionTest1 {
public void test() {
ArrayList<Integer> arrayList = new ArrayList<Integer>();
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) {
arrayList.remove(integer);
}
}
}
public static void main(String[] args) {
ExceptionTest1 test = new ExceptionTest1();
test.test();
}
}
上面的代码是很常见的方式,即循环体中操作数据,新增或删除都会触发
同样的,在for循环体中也会复现:
for( Integer integer :arrayList){
if(integer.intValue() == 5){
arrayList.remove(integer);
}
}
1.2、问题原因分析
使用Iterator遍历ArrayList, 抛出异常的是iterator.next()。看下Iterator 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];
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
在next方法中首先调用了checkForComodification方法,该方法会判断modCount是否等于expectedModCount,不等于就会抛出java.util.ConcurrentModificationExcepiton异常。
我们接下来跟踪看一下modCount和expectedModCount的赋值和修改。
modCount是ArrayList的一个属性,继承自抽象类AbstractList,用于表示ArrayList对象被修改次数。
protected transient int modCount = 0;
整个ArrayList中修改modCount的方法比较多,有add、remove、clear、ensureCapacityInternal等,凡是设计到ArrayList对象修改的都会自增modCount属性。
在创建Iterator的时候会将modCount赋值给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() 方法会在删除当前迭代对象的同时维护索引的一致性。
再来分析下第二种for循环抛异常的原因:
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()方法。我们看看该方法源码为什么这个方法能避免抛出异常
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();
}
}
在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();
while (iterator.hasNext()) {
Integer integer = iterator.next();
if (integer.intValue() == 5) {
iterator.set(Integer.valueOf(6));
iterator.remove();
iterator.add(integer);
}
}
}
二、 多线程情况下的问题分析及解决方案
2.1 问题复现
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();
}
}
}
}
这段代码是main主线程循序数组并打印,新起的thread负责在main线程运行期间触发一次删除操作,我们看下执行结果:
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线程modCount | main线程expectedModCount |
---|---|---|
remove之前 | 20 | 20 |
remove之后 | 21 | 20 |
很明显,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
有坑!要明白原理再用,否则你就呆坑里吧。
import java.util.List;
import java.util.ListIterator;
import java.util.concurrent.CopyOnWriteArrayList;
public class ScheduleExecutorServiceTest2 {
public static void main(String[] args) {
final List<Integer> list = new CopyOnWriteArrayList<Integer>();
for (int i = 0; i < 20; i++) {
list.add(Integer.valueOf(i));
}
Thread thread1 = new Thread(new Runnable() {
public void run() {
ListIterator<Integer> iterator = list.listIterator();
while (iterator.hasNext()) {
System.out.println("thread1 " + iterator.next().intValue());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
Thread thread2 = new Thread(new Runnable() {
public void run() {
for (Integer integer : list) {
System.out.println("thread2 " + integer.intValue());
if (integer.intValue() == 5) {
list.remove(integer);
}
}
for (Integer integer : list) {
System.out.println("thread2 again " + integer.intValue());
}
}
});
thread1.start();
thread2.start();
}
}
先不分析,看执行结果,这个执行结果重点关注字体加粗部分。
thread1 0
thread2 0
thread2 1
thread2 2
thread2 3
thread2 4
thread2 5
thread2 6
thread2 7
thread2 8
thread2 9
thread2 10
thread2 11
thread2 12
thread2 13
thread2 14
thread2 15
thread2 16
thread2 17
thread2 18
thread2 19
thread2 again 0
thread2 again 1
thread2 again 2
thread2 again 3
thread2 again 4
thread2 again 6
thread2 again 7
thread2 again 8
thread2 again 9
thread2 again 10
thread2 again 11
thread2 again 12
thread2 again 13
thread2 again 14
thread2 again 15
thread2 again 16
thread2 again 17
thread2 again 18
thread2 again 19
thread1 1
thread1 2
thread1 3
thread1 4
thread1 5
thread1 6
thread1 7
thread1 8
thread1 9
thread1 10
thread1 11
thread1 12
thread1 13
thread1 14
thread1 15
thread1 16
thread1 17
thread1 18
thread1 19
我们先分析thread2的输出结果,第一次遍历将4 5 6都输出,情理之中;第一次遍历后删除掉了一个元素,第二次遍历输出4 6,符合我们的预期。
再来看下thread1的输出结果,有意思的事情来了,thread1 仍然输出了4 5 6,什么鬼?thread1和thread2都是遍历list,list在thread1遍历第二个元素的时候就已经删除了一个元素了,为啥还能输出5?
为了了解这个问题,需要了解CopyOnWriteArrayList是如何做到一边遍历的同时还能一边修改并且还不抛异常的。
这里说一下CopyOnWriteArrayList的解决思路,其实很简单:
private transient volatile Object[] array;
CopyOnWriteArrayList本质上是对array数组的一个封装,一旦CopyOnWriteArrayList对象发生任何的修改都会new一个新的Object[]数组newElement,在newElement数组上执行修改操作,修改完成后将newElement赋值给array数组(array=newElement)。
因为array是volatile的,因此它的修改对所有线程都可见。
了解了CopyOnWriteArrayList的实现思路之后,我们再来分析上面代码test6为什么会出现那样的输出结果。先来看下thread1和thread2中用到的两种遍历方式的源码:
public void forEach(Consumer<? super E> action) {
if (action == null) throw new NullPointerException();
// 在遍历开始前获取当前数组
Object[] elements = getArray();
int len = elements.length;
for (int i = 0; i < len; ++i) {
@SuppressWarnings("unchecked") E e = (E) elements[i];
action.accept(e);
}
}
public ListIterator<E> listIterator() {
return new COWIterator<E>(getArray(), 0);
}
static final class COWIterator<E> implements ListIterator<E> {
/** Snapshot of the array */
private final Object[] snapshot;
/** Index of element to be returned by subsequent call to next. */
private int cursor;
private COWIterator(Object[] elements, int initialCursor) {
cursor = initialCursor;
// 初始化为当前数组
snapshot = elements;
}
public void remove() {
// 已经不支持Iterator remove操作了!!
throw new UnsupportedOperationException();
}
public boolean hasNext() {
return cursor < snapshot.length;
}
@SuppressWarnings("unchecked")
public E next() {
if (! hasNext())
throw new NoSuchElementException();
return (E) snapshot[cursor++];
}
// 此处省略其他无关代码
}
这两种遍历方式有个共同的特点:都在初始化的时候将当前数组保存下来了,之后的遍历都将会遍历这个数组,而不管array如何变化。
时间点 | CopyOnWriteArrayList的array | thread1 iterator 初始化的Object数组 | thread2 第一次遍历forEach初始化的Object数组 | thread2 第二次遍历forEach初始化的Object数组 |
thread start | 假设为A | A | A | / |
thread2 调用remove方法之后 | 假设为B | A | A | B |
有了这个时间节点表就很清楚了,thread1和thread2 start的时候都会将A数组初始化给自己的临时变量,之后遍历的也都是这个A数组,而不管CopyOnWriteArrayList中的array发生了什么变化。因此也就解释了thread1在thread2 remove掉一个元素之后为什么还会输出5了。在thread2中,第二次遍历初始化数组变成了当前的array,也就是修改后的B,因此不会有Integer.valueOf(5)这个元素了。
从test6执行结果来看,CopyOnWriteArrayList确实能解决一边遍历一边修改并且还不会抛异常,但是这也是有代价的:
(1) thread2对array数组的修改thread1并不能被动感知到,只能通过hashCode()方法去主动感知,否则就会一直使用修改前的数据
(2) 每次修改都需要重新new一个数组,并且将array数组数据拷贝到new出来的数组中,效率会大幅下降
此外CopyOnWriteArrayList中的ListIterator实现是不支持remove、add和set操作的,一旦调用就会抛出UnsupportedOperationException异常,因此test6注释代码34-41行中如果运行是会抛异常的。
来源:CSDN
作者:m0_45406092
链接:https://blog.csdn.net/m0_45406092/article/details/103903276