在遍历集合类的元素时,若试图修改该集合则会报ConcurrentModifyException异常,本文介绍了该异常产生的各种情况、原因和解决方案。image-20210707144719236

参考资料

  1. Java并发–ConcurrentModificationException(并发修改异常)异常原因和解决方法
  2. ConcurrentModifyException的产生原因及如何避免
  3. 并发进阶(四)并发修改异常

ConcurrentModifyException是什么

原文:参考资料2

ConcurrentModificationException这个异常是从JDK1.2时就存在。当方法检测到对象的并发修改,但不允许这种修改时,抛出此异常。这个异常在单线程和多线程运行环境都可以产生。

某个线程在 Collection 上进行迭代时,通常不允许另一个线性修改该Collection。通常在这些情况下,迭代的结果是不确定的。如果检测到这种行为,一些迭代器实现(包括JRE提供的所有通用collection实现)可能选择抛出此异常。

执行该操作的迭代器称为快速失败迭代器,因为迭代器很快就完全失败,而不会冒着在将来某个时间任意发生不确定行为的风险。迭代器的快速失败行为无法得到保证,因为一般来说,不可能对是否出现不同步并发修改做出任何硬性保证。快速失败操作会尽最大努力抛出ConcurrentModificationException。

因此,为提高此类操作的正确性而编写一个依赖于此异常的程序是错误的做法,正确做法是:ConcurrentModificationException 应该仅用于检测 bug。

1 使用迭代器遍历时修改

场景

1
2
3
4
5
6
7
8
9
ArrayList<Integer> list = new ArrayList<Integer>();
// ...(向list进行添加等相关操作)
// 需求:删除list中的2
Iterator<Integer> iterator = list.iterator();
while(iterator.hasNext()){
Integer integer = iterator.next();
if(integer==2)
list.remove(integer); // 报错
}

原因

查看ArrayList的源码,发现并没有iterator()方法的实现,那么说明它继承了其父类AbstractList的该方法。查看:

1
2
3
public Iterator<E> iterator() {
return new Itr();
}

返回一个Itr类型的对象,继续查看该类的实现,是一个私有的内部类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
private class Itr implements Iterator<E> {
int cursor = 0;
int lastRet = -1;
int expectedModCount = modCount;
public boolean hasNext() {
return cursor != size();
}
public E next() {
checkForComodification();
try {
E next = get(cursor);
lastRet = cursor++;
return next;
} catch (IndexOutOfBoundsException e) {
checkForComodification();
throw new NoSuchElementException(); // ①
}
}
public void remove() {
if (lastRet == -1)
throw new IllegalStateException();
checkForComodification();

try {
AbstractList.this.remove(lastRet);
if (lastRet < cursor)
cursor--;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException e) {
throw new ConcurrentModificationException(); // ②
}
}

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

这个AbstractList的迭代器总共有三处会抛出ConcurrentModificationException。

先理解它是如何实现的:通过变量名字可以知道cursor代表当前指向的集合内元素,是下一次调用next()方法时会返回的元素。lastRet代表的是上一次返回的元素,用于删除方法。

剩下的expectedModCount就是产生这个异常的原因:它表示对List对象修改次数的期望值,初始值为modCount,而modCount是AbstractList类中的一个成员变量:

1
protected transient int modCount = 0;

这个变量表示对List的修改次数,查看ArrayList的add()和remove()方法就可以发现,每次调用add()方法或者remove()方法就会对modCount进行加1操作。

那么,遍历场景中抛出ConcurrentModificationException异常的原因就清晰了:由于在使用迭代器的过程中调用了ArrayList本身的remove方法,使modCount++,再调用迭代器的next()方法时通过checkForComodification()抛出了该异常,即代码③处。

单线程解决方法 1

原文:参开资料1

若是单线程,简单明了,调用迭代器重新实现的remove就好啦,在里面迭代器更新了expectedModCount。

1
2
3
4
5
6
Iterator<Integer> iterator = list.iterator();
while(iterator.hasNext()){
Integer integer = iterator.next();
if(integer==2)
iterator.remove(); // 呐
}

或者直接就不用迭代器,用for+局部变量i的形式遍历集合元素,用增强for循环不行,因为底层也是使用迭代器。

单线程解决方法2

原文:参考资料2

将ArrayList集合改为CopyOnWriteArrayList:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.util.concurrent.CopyOnWriteArrayList;

public class Demo2 {

public static void main(String[] args) {
/** 初始化集合类*/
CopyOnWriteArrayList<TestObj> list = new CopyOnWriteArrayList<>();
for (int i = 0; i < 100; i++) {
list.add(new TestObj(i));
}

/** 遍历时删除元素*/
for (TestObj obj : list) {
if (obj.getValue() < 10) {
/** 这里不会抛出ConcurrentModificationException*/
list.remove(obj);
}
}

System.out.println();
}
}

单线程两种方案比较

每一个方案都把上述操作执行一百万次,可以得到:

使用迭代器自带的remove方法 1919
用CopyOnWriteArrayList替代 7013

因此,单线程环境中推荐使用迭代器自带的remove方法来删除元素。

多线程解决方案

若是多线程,这样写就不行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class Test {
static ArrayList<Integer> list = new ArrayList<Integer>();
public static void main(String[] args) {
list.add(1);
list.add(2);
list.add(3);
list.add(4);
list.add(5);
Thread thread1 = new Thread(){
public void run() {
Iterator<Integer> iterator = list.iterator();
while(iterator.hasNext()){
Integer integer = iterator.next();
System.out.println(integer);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
};
Thread thread2 = new Thread(){
public void run() {
Iterator<Integer> iterator = list.iterator();
while(iterator.hasNext()){
Integer integer = iterator.next();
if(integer==2)
iterator.remove();
}
};
};
thread1.start();
thread2.start();
}
}

这样写还是会报ConcurrentModificationException异常,

有可能有朋友说ArrayList是非线程安全的容器,换成Vector就没问题了,实际上换成Vector还是会出现这种错误。

原因在于,虽然Vector的方法采用了synchronized进行了同步,但是由于Vector是继承的AbstarctList,因此通过Iterator来访问容器的话,事实上是不需要获取锁就可以访问。那么显然,由于使用iterator对容器进行访问不需要获取锁,在多线程中就会造成当一个线程删除了元素,由于modCount是AbstarctList的成员变量,因此可能会导致在其他线程中modCount和expectedModCount值不等。

就比如上面的代码中,很显然iterator是线程私有的,

初始时,线程1和线程2中的modCount、expectedModCount都为0,

当线程2通过iterator.remove()删除元素时,会修改modCount值为1,并且会修改线程2中的expectedModCount的值为1,

而此时线程1中的expectedModCount值为0,虽然modCount不是volatile变量,不保证线程1一定看得到线程2修改后的modCount的值,但是也有可能看得到线程2对modCount的修改,这样就有可能导致线程1中比较expectedModCount和modCount不等,而抛出异常。

因此一般有2种解决办法:

1)在使用iterator迭代的时候使用synchronized或者Lock进行同步;

2)与单线程一样,可以使用并发容器CopyOnWriteArrayList代替ArrayList和Vector;使用ConcurrentHashMap替换HashMap。

2 集合类的toString()方法

原文:参考资料3

Vector的toString()方法中隐式地使用了迭代器:

1
2
3
public synchronized String toString() {
return super.toString();
}

AbstractCollection.toString()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public String toString() {
Iterator<E> it = iterator();
if (! it.hasNext())
return "[]";

StringBuilder sb = new StringBuilder();
sb.append('[');
for (;;) {
E e = it.next();
sb.append(e == this ? "(this Collection)" : e);
if (! it.hasNext())
return sb.append(']').toString();
sb.append(',').append(' ');
}
}

从上面的代码中可以看出Vector调用了AbstractCollection.toString()方法,而AbstractCollection.toString()方法使用了迭代器遍历整个容器。

使用调用Vector.toString()方法时不会出现并发访问异常,因为Vector.toString()方法加锁了,当调用toString()方法时其它线程不能修改容器,因此不会抛出并发访问异常。但是ArrayList、LinkedList这些非线程安全的容器类就不能保证调用toString()方法的时候没有其它线程修改容器了,因此调用这些类的toString()方法有可能导致并发访问异常。

避免异常的方法

加锁

避免异常的方法也不难,最简单的方式就是和Vector.toString()一样,我们每次调用迭代器的时候都对Vector的对象加锁即可。比如下面的代码:

1
2
3
4
5
synchronized(vct) {
truefor(String str : vct) {
truetrueSystem.out.println(str);
true}
}

这样就可以避免在遍历的时候其它线程对容器修改,但是如果容器特别大的时候会导致遍历容器需要等很久,而其它线程必须等待,这样就降低了系统的性能,因此这种解决方案并不是完美的。

分段遍历

另一种方式就是分段遍历容器,比如容器中一共有十万个元素,我们通过subList()方法先获得前一千个元素,遍历这个子集,然后再获得从一千到两千的元素……以此类推。但是这种方案也是有缺陷的,因为每次拿到的子集都相当于一个快照,在遍历子集的时候别的线程可能已经修改了这个容器,因此这种方法要求客户端代码对数据一致的敏感性不高,对敏感性高就只能使用加锁的方法。

留言

⬆︎TOP