在 Java
的集合类中,在遍历修改时,会发生两种错误,一种是 fail-fast
,一种是 fail-safe
。
这两种机制分别对应的是线程不安全和线程安全的集合类。
先说fail-fast
,在使用 Java
集合的过程中,应该都碰到过 ConcurrentModificationException
。
我是大二那年进学校工作室考核期时,写程序的时候第一次碰到这个异常的。
原因就是我在for-each
中删除掉了一个元素。当时自然是懵懵的,也不知道这其实是 Java
集合的一个知识点:fail-fast
。
fail-fast 快速失败
如果在遍历一个集合的过程中,该集合对象的结构发生了改变,则集合会尽最大努力抛出 ConcurrentModificationException
。
存在两种情况,会抛出该异常:
单线程环境下
第一种是在单线程的环境下,使用 Iterator
对象遍历集合对象时,修改了集合对象的结构,比如增加或者删除元素。
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("a"); list.add("b"); list.add("c"); list.add("d");
Iterator<String> itr = list.listIterator();
while(itr.hasNext()){
String s = itr.next();
if (s.equals("b")) {
list.remove(s);
}
}
System.out.println(list);
}
输出
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 com.jia.nabao.common.ListThreadSafeDemo.main(ListThreadSafeDemo.java:21)
使用 for-each
时,做修改也是一样的效果。因为 for-each
实际运行时也是通过 Iterator
对象进行工作的。
该异常是由 java.util.ArrayList.Itr#checkForComodification
方法抛出的,这个方法的源码如下:
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
通过判断modCount
和 expectedModCount
是否相等,可以判断出来集合结构是否被改变了。
modCount
是属于 ArrayList
的(实际上是在 AbstractList
声明的)。
expectedModCount
是属于 ListItr
的,ListItr
是 ArrayList
的迭代器,继承Itr
并实现了ListIterator
接口。
在初始化 ListItr
时,将 modCount
赋值给 expectedModCount
,然后在迭代时,需要保证两者始终相等。
现在来看两个 remove
方法,然后就可以知道在这段程序中,modCount
和 expectedModCount
不相等的原因,以及什么时候会调用 checkForComodification
方法,为什么我前面说fail-fast
是尽最大努力抛出异常。
java.util.ArrayList#remove(int)
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
我们重点关注 modCount++
这句代码,意思是结构发生改变时,在这里是删除了元素,modCount
自增以表示有一个操作改变了集合结构,添加或者删除。
能引起 modCount
改变的,大概还有这些方法(包括但不止这些):
再看迭代器的删除方式,java.util.ArrayList.Itr#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();
}
}
可以看到 try
代码块中在移除元素之后,会将 modCount
赋值给 expectedModCount
,因此两个值可以保持相等。ArrayList.this.remove(lastRet);
调用的实际上就是上面讲的那个删除方法。
在这里,我们结合一下上面抛出异常的的示例代码,分析一下,modCount
和expectedModCount
是什么时候不相等的,然后抛出异常需要什么条件。
我直接用截图加文字说明
当我们观察输出结果时,会发现异常是从 next()
方法抛出的,因为方法内部调用了 checkForComodification()
方法,从前面可以知道,该方法会判断两个数值是否相等,不相等时,会抛出异常。
至此,真相大白,是因为我们调用了 list.remove()
导致的,只要我们改为 itr.remove()
便可以避免该异常。
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];
}
现在我们可以说一下,为什么是尽最大努力抛出异常了。
从前面 next()
方法我们可以知道一个点,就是如果不调用 next()
方法的话,就不会抛出异常。
所以没抛异常不代表你当前的操作是没问题,因为集合结构可能已经悄悄改变了,你当前的迭代器已经废了,凉凉。但是集合会在你还要继续用迭代器时,马上发出警告。
实际使用中,我认为不应该捕捉和依赖ConcurrentModificationException
,这个异常应该是用来检验代码是否有bug的。
多线程环境下
多线程环境下,如果多个线程对同一个集合进行修改,也是会出现 ConcurrentModificationException
异常的。
/**
* 验证ArrayList的线程不安全,并提供几种线程安全的列表的解决方案
*
* @author linjinjia linjinjia047@163.com
* @date 2021/3/19 22:38
*/
public class ListThreadSafeDemo {
public static void main(String[] args) {
List<Integer> list = new ArrayList<Integer>();
for (int i = 0; i < 10; i++) {
final int j = i;
new Thread(() -> {
list.add(j);
System.out.println(list);
}, "" + i).start();
}
}
}
输出(输出结果不是唯一的,也有可能不抛出异常):
我们简单讲一下,为什么这里也抛出了异常。
通过输出结果,我们可以看到依旧是因为调用了 next()
方法才抛出这个异常。可是我们并没有在代码里使用过迭代器,为什么还会调用该方法呢?
这就要看到是谁调用了next()
方法了,通过调用方法栈,可以看到是调用了 java.util.AbstractCollection#toString
方法,可以看到这个方法是有用到迭代器的。
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(' ');
}
}
说句题外话,为什么使用System.out.println(list);
会调用对象的 toString
呢?可以看看下面的源码。
public void println(Object x) {
String s = String.valueOf(x);
synchronized (this) {
print(s);
newLine();
}
}
public static String valueOf(Object obj) {
return (obj == null) ? "null" : obj.toString();
}
因此,在多线程环境下,并发修改集合的结构是可能抛出 ConcurrentModificationException
异常的。
java.util
里面的集合大部分都是 fail-fast 的,当然也有例外:比如Vector
和HashTable
。
fail-safe 安全失败
起先我是不知道有这种机制的,我是只菜鸟。我是在网上了解 fail-fast
机制的时候,才知道有这个的。
fail-safe
机制是为线程安全的集合准备的,可以避免像fail-fast
一样在并发使用集合的时候,不断地抛出异常。
正如前一句的描述,线程安全的集合的并发修改并不会抛出异常,fail-safe
并不能直观地看到它的表现。
我们可以通过 CopyOnWriteArrayList
类来看看,这个机制是怎么样的,当然看这个之前应该先对 CopyOnWriteArrayList
这个容器有个基本认识。可以看看这里
再看看 java.util.concurrent.CopyOnWriteArrayList.COWIterator
,它是 CopyOnWriteArrayList
的迭代器。COWIterator
在创建时,是直接使用 CopyOnWriteArrayList
的数组的,然后进行迭代的。
当容器的结构发生改变时,并不会影响迭代器数组。因为迭代器的数组只是 CopyOnWriteArrayList
的一个历史快照,而且也没有校验什么 modCount
的操作,所以不会抛出异常。
既然读取的是一个历史快照,那么在迭代器遍历的时候,如果集合的结构修改了,迭代器是不知道的。这就导致了说,遍历的内容并不是最新的,可能和集合的最新状态不一致,但是不会抛出异常,这就是安全失败的意思吧。
public static void main(String[] args) {
List<String> list = new CopyOnWriteArrayList<>();
list.add("a");
list.add("b");
list.add("c");
list.add("d");
Iterator<String> itr = list.listIterator();
while (itr.hasNext()) {
String s = itr.next();
if (s.equals("b")) {
list.remove(s);
}
}
System.out.println(list);
}
评论区