概述
环境:JDK 1.8.0_111
文章目录
- 概述
- 一、单线程情况下问题分析及解决方案
- 1.1 问题复现
- 1.2 问题原因分析
- 1.3 问题解决方案
- 二、 多线程情况下的问题分析及解决方案
- 2.1 问题复现
- 2.2 问题分析
- 2.3 多线程下的解决方案
- 2.3.1 方案一:加同步锁
- 2.3.2 方案二:使用CopyOnWriteArrayList
- CopyOnWriteArrayList注意事项
- 三、汇总
- 四 remove总结
- 4.1 ArrayList
- 4.2 CopyOnWriteArrayList
概述
在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
异常。
我们接下来跟踪看一下modCount
和expectedModCount
的赋值和修改。
modCount
是ArrayList变量,共享的,即使是多线的,访问的都是同一个值expectedModCount
是迭代器产生的一个私有变量,多个迭代器会产生多个
modCount
是ArrayList的一个属性,继承自抽象类AbstractList,用于表示ArrayList对象被修改次数。
protected transient int modCount = 0;
整个ArrayList
中修改modCount
的方法比较多,有add
、remove
、clear
、ensureCapacityInternal
等,凡是设计到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线程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
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的array | thread1 遍历Object数组 | thread2 第一次遍历Object数组 | thread2 第二次遍历Object数组 |
---|---|---|---|---|
thread2 调用remove方法前 | A (初始数组) | A | A | / |
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
实现是不支持remove
、add
和set
操作的,一旦调用就会抛出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()
形式删除
三、汇总
-
ArrayList是非线程安全的
原因是未加锁 -
ArrayList在迭代期间,可能引起ConcurrentModificationException异常
原因是如果数组发生删除操作,Iterator迭代器在迭代期间会进行modCount != expectedModCount
比较,导致抛出异常java.util.ConcurrentModificationException,作用是提醒用户发生了数据不一致的情况,请用户捕获处理 -
可以使用CopyOnWriteArrayList替代ArrayList
作用是正在迭代的数组不会产生异常ConcurrentModificationException,原因是读操作是个备份机制,别的操作不影响当前的快照;因为快照机制,产生了弱一致性问题,即数组发生变化,不会立即体现在快照数组中 -
CopyOnWriteArrayList也有缺陷,不能在迭代期间调用remove,add操作
原因是读操作是对快照进行的,如果在迭代期间调用remove(),只会删除快照里面的数据,而不会影响原数组,故而直接禁止调用相关的方法。 -
由于采用复制原理,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 通过写操作新建数据方式,解决了读写的问题,通过锁实现了写写之间的问题。
最后
以上就是爱撒娇钻石为你收集整理的【集合类】 1 java.util.ConcurrentModificationException异常详解&ArrayList&CopyOnWriteArrayList原理探究概述四 remove总结的全部内容,希望文章能够帮你解决【集合类】 1 java.util.ConcurrentModificationException异常详解&ArrayList&CopyOnWriteArrayList原理探究概述四 remove总结所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复