一、异常再现及解决方案
在遍历ArrayList并对其进行删除操作时,跳出java.util.ConcurrentModificationException异常,先上错误代码:
@Test
public void test1() {
List<String> list = new ArrayList<String>();
list.add("小白");
list.add("小紫");
list.add("小红");
list.add("小绿");
list.add("小兰");
for (String str : list) {
if(str.equals("小紫")){
list.remove(str);
}
}
}
@Test
public void test2() {
List<String> list = new ArrayList<String>();
list.add("小白");
list.add("小紫");
list.add("小红");
list.add("小绿");
list.add("小兰");
Iterator<String> it = list.iterator();
while(it.hasNext()){
String x = it.next();
if(x.equals("小紫")){
list.remove(x);
}
}
}
我出现的是test1的问题,还有一种就是test2也会有这样的问题。
其实不只是遍历ArrayList会出现 ConcurrentModificationException 异常,只要是集合 Collection 的子类都会出现这个问题。
正确的用法如下:
@Test
public void test6() {
List<String> list = new ArrayList<String>();
list.add("小白");
list.add("小紫");
list.add("小红");
list.add("小绿");
list.add("小兰");
Iterator<String> it = list.iterator();
while(it.hasNext()){
String x = it.next();
if(x.equals("小紫")){
it.remove();
}
}
}
循环要用iterator.hasNext();删除元素不能用集合的remove(),而要用迭代器iterator的remove。注意好这两点就不会报 ConcurrentModificationException 异常啦!
二、ConcurrentModificationException异常分析
1、test1 分析
test1使用的是foreach遍历(增强for循环),进入源码中看它的实现如下
我们可以看到,他抛出异常的是因为 modCount != expectedModCount ,那么需要先弄清楚这两个参数是什么。
modCount 该参数继承自抽象类AbstractList,在AbstractList中对他的解释如下,直译第一句:结构被修改的次数。也就是ArrayList这个对象被修改的次数。expectedModCount,顾名思义,期望被修改的次数,主要是循环 遍历时用来临时保存modCount的参数,防止对象还在循环遍历的时候被突然修改而导致遍历出错的情况。当遍历过程中出现了unexpectedly(出乎意料地)的操作时,按照 fail-fast (快速失败)原则便会立刻停止并报出 ConcurrentModificationException 异常。
回到案例分析,在ArrayList执行add和remove操作时(remove方法中会调用fastRemove方法),都会执行modCount++的自增。
于是乎,在 test1 的案例中add了五个小盆友,所以此时 modCount==5。接下来的foreach遍历操作将 modCount 赋值给了 expectedModCount ,所以它也是5。test1 中循环到‘小紫’的时候我执行了remove操作,因此 modCount 自增变成了6。这时modCount 跟 expectedModCount 就不相等了,于是便报出 ConcurrentModificationException 异常。
2、test2 分析
两个案例出错的原理差不多。同样看源码,it.next()的实现源码如下
在 next 中首先会执行 checkForComodification() 方法,去判断 modCount 和 expectedModCount是否相等,不相等就报 ConcurrentModificationException 异常。
同理,在 test2 中由于循环时进行了 remove 操作,导致 modCount 自增,modCount != expectedModCount,所以就报出了 ConcurrentModificationException 异常。
3、正确用法分析
正确用法中,主要是使用了 Iterator 迭代器的 remove 方法。
原理(来源):
Iterator 是工作在一个独立的线程中,并且拥有一个 mutex 锁。
Iterator 被创建之后会建立一个指向原来对象的单链索引表,当原来的对象数量发生变化时,这个索引表的内容不会同步改变。当索引指针往后移动的时候就找不到要迭代的对象,按照 fail-fast 原则 Iterator 会马上抛出 java.util.ConcurrentModificationException 异常。
所以 Iterator 在工作的时候是不允许被迭代的对象被改变的。但你可以使用 Iterator 本身的方法 remove() 来删除对象, Iterator.remove() 方法会在删除当前迭代对象的同时维护索引的一致性。
解释一下就是 Iterator.remove() 方法中多了一个 expectedModCount=modCount,维护了索引的一致性,所以它就不会报 ConcurrentModificationException 异常。
三、拓展
上面提到的问题和分析都是在单线程的情况下的,接下来讲讲多线程下出现异常的情况,虽然我还没遇到,但既然学到了就将它记录下来,方便日后复习。
1、问题再现
public static void main(String[] args) {
test1();
}
private static void test1() {
final List<String> list = new ArrayList<String>();
list.add("小白");
list.add("小紫");
list.add("小红");
list.add("小绿");
list.add("小兰");
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String str = it.next();
System.out.println("Thread1 --- " + str);
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String str = it.next();
if (str.equals("小紫")) {
it.remove();
}else {
System.out.println("Thread2 --- " + str);
}
}
}
});
thread1.start();
thread2.start();
}
运行结果:
案例中主要开启了两个线程,线程1是延时遍历,线程2是遍历+删除操作,注意用的都是同一个 list 集合。结果我们可以看到 thread1 遍历了第一个后进入了 sleep 休眠状态,此时 thread2 开始遍历并送走了‘小紫’(remove后modCount +1),遍历完成后, thread1 休眠完毕,准备遍历第二个元素‘小紫’,但此时小紫已被移除,同时modCount != expectedModCount,所以就会报ConcurrentModificationException 异常。结合表格看可能会更清晰:
时间点 | arrayList.modCount | thread1 iterator.expectedModCount | thread2 iterator.expectedModCount |
thread start,初始化iterator | 5 | 5 | 5 |
thread2.remove()调用之后 | 6 | 5 | 6 |
两个线程都有各自创建的 Iterator 迭代器,一个线程 remove 后 expectedModCount 被重新赋值,另一个线程的 expectedModCount 是无法了解并同步的,所以 remove 操作后 thread1 的expectedModCount 依然是5,跟 modCount 自然就不相等了。
2、解决方案
(1)方案一:线程加锁
给每个线程加同步锁,就是在遍历前将 list 锁住,仅执行当前线程的循环遍历操作,其他线程等待。本质上和单线程类似,优点就是不会报异常,缺点就是多变单,效率降低了。
public static void main(String[] args) {
test1();
}
private static void test1() {
final List<String> list = new ArrayList<String>();
list.add("小白");
list.add("小紫");
list.add("小红");
list.add("小绿");
list.add("小兰");
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (list) {
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String str = it.next();
System.out.println("Thread1 --- " + str);
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (list) {
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String str = it.next();
if (str.equals("小紫")) {
it.remove();
}else {
System.out.println("Thread2 --- " + str);
}
}
}
}
});
thread1.start();
thread2.start();
}
(2)方案二:使用CopyOnWriteArrayList
public static void main(String[] args) {
test1();
}
private static void test1() {
final List<String> list = new CopyOnWriteArrayList<String>();
list.add("小白");
list.add("小紫");
list.add("小红");
list.add("小绿");
list.add("小兰");
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String str = it.next();
System.out.println("Thread1 --- " + str);
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String str = it.next();
if (str.equals("小紫")) {
list.remove("小紫");
}else {
System.out.println("Thread2 --- " + str);
}
}
}
});
thread1.start();
thread2.start();
}
运行结果:
可以看到,thread2 已经 remove 了小紫,thread1 仍然能遍历出来,并且没报错。原因的话继续从源码中找,直接看下 CopyOnWriteArrayList 的源码。
可以看到,在初始化迭代器的时候,list 对象其实已经被复制保存在 private final Object[] snapshot; 这个临时对象里面,接下来的遍历用的都是这个保存复制的对象。所以 thread1 和 thread2 在初始化 Iterator 迭代器时,各自复制保存了 list 对象,操作便互不相干。即使在 thread2 中 remove 掉了“小紫”,thread1 中用的是已经复制保存好的不会受到影响,也就能打印出“小紫”了。
时间点 | CopyOnWriteArrayList对象 | thread1 | thread2 |
thread.start() | A | Copy-A1 | Copy-A2 |
thread2.remove后 | B | Copy-A1 | B |
结合表格看可能会好理解些,thread2 使用copy-A在 remove 操作后,会通过 setArray 将修改过后的对象B,赋值回给原来的 array 对象。
使用 CopyOnWriteArrayList 有几个需要注意的地方。
1、从 CopyOnWriteArrayList 的源码可以看到,CopyOnWriteArrayList 创建的 Iterator 迭代器是不能 remove、set 和 add 操作的,否则会 throw new UnsupportedOperationException(); 提示你不支持此操作。
但是呢,是可以用 CopyOnWriteArrayList 自带的 remove 操作的,所以我在案例中没有用迭代器的 remove,用的是 list 自带的 remove。不会报错是因为他每次 remove 操作后会将修改后的array 对象重新赋值给 expectedArray ,这样在遍历下一个时 checkForComodification() 就不会报错了。跟上面的原理差不多,只是这里 modCount 直接换成了 array 对象。
2、 第二点需要注意的是,thread2对array数组的修改thread1并不能被动感知到,只能通过hashCode()方法去主动感知。
3、最后一点是效率问题,可以看到整个过程 array 对象拷贝来拷贝去的,每次修改还需要重新 new 一个 array 对象,这样看效率自然就降低了。