我是靠谱客的博主 爱撒娇钻石,最近开发中收集的这篇文章主要介绍【集合类】 1 java.util.ConcurrentModificationException异常详解&ArrayList&CopyOnWriteArrayList原理探究概述四 remove总结,觉得挺不错的,现在分享给大家,希望可以做个参考。

概述

环境: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异常。

我们接下来跟踪看一下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 通过写操作新建数据方式,解决了读写的问题,通过锁实现了写写之间的问题。

最后

以上就是爱撒娇钻石为你收集整理的【集合类】 1 java.util.ConcurrentModificationException异常详解&ArrayList&CopyOnWriteArrayList原理探究概述四 remove总结的全部内容,希望文章能够帮你解决【集合类】 1 java.util.ConcurrentModificationException异常详解&ArrayList&CopyOnWriteArrayList原理探究概述四 remove总结所遇到的程序开发问题。

如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(40)

评论列表共有 0 条评论

立即
投稿
返回
顶部