问题
今天遇到一个问题,在遍历集合对象的时候同时修改了迭代的对象,导致了ConcurrentModificationException。这是一个比较常见但是又非常隐蔽的错误,特此来总结下这个Java里的fast-fail机制。
示范代码
ArrayListMultimap<String, Integer> map = ArrayListMultimap.create();
map.put("a", 1);
map.put("b", 2);
for (String k : map.keySet()) {
List<Integer> integers = map.get(k);
integers.removeIf(integer -> integer.equals(1));
}
迭代
对于集合对象,我们经常需要进行各种遍历,遍历的方式也从最开始的迭代器变得更加丰富,Java的语法糖给开发提供了更多的选择。这次主要分析下Iterator和for each的方式的区别。
对于一个集合的迭代,我们可以使用ArrayList来举例。我们可以用以下两种方式:
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
// 构造一个集合的索引
Iterator<Integer> iterator = list.iterator();
// 只是判断下一个
while (iterator.hasNext()) {
// next每次调用会指针自动滑动
Integer next = iterator.next();
}
第二种方式:
for (Integer i : list) {
}
或许我们记得如果要修改对象的话,就不能使用for的方式去迭代,因为这会触发fail-fast机制,这个名字比较隐晦,其实就是做一个前置校验而已,就如同我们经常写的各种前置check逻辑。如果最基本的check都满足不了,就没必要在进行后续的操作了,这就是所谓的fail-fast。
我们用两种方式去删除元素:
// 第一种方式,不抛出异常
while (iterator.hasNext()) {
iterator.remove();
}
// 抛异常
for (Integer i : list) {
list.remove(0);
}
为什么会有这样一种机制呢。我们预期是想去移除集合的对象,我们先来看为什么会抛出异常。根据抛出堆栈,我们追踪到抛出的异常行:
private class Itr implements Iterator<E> {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;
// .... 部分代码省略
final void checkForComodification() {
// 这里抛出的异常
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
看来for each 是使用的这个底层迭代器去进行迭代操作的。其本质上,也是做这样一个逻辑:判断是否有下一个节点,如果有就获取下一个节点。其中获取下一个节点的逻辑:
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];
}
checkForComodification这个就是前面前置校验,就是fail-fast机制,然后:
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
这里就异常了。也就是说modCount与expectedModCount不相等,modCount是List的基类AbstractList定义的属性。这个属性是该迭代器构造的时候记录的该集合改变的次数,在迭代开始的时候会赋值给expectedModCount。然后每次去获取下一个元素的时候会去校验,这个值是否改变,如果改变就会异常。这就好理解了,因为我们在迭代器中去remove了这个值,这会去递增modCount:
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;
}
这就导致了前面fail-fast异常。
那为什么使用原生的迭代器为什么没这个问题呢。我们来看下:
public Iterator<E> iterator() {
return new Itr();
}
/**
* An optimized version of AbstractList.Itr
*/
private class Itr implements Iterator<E> {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;
Itr() {}
public boolean hasNext() {
return cursor != size;
}
@SuppressWarnings("unchecked")
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];
}
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();
}
}
// 省略部分代码
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
iterator默认是构造了Itr。用iterator去remove会去更新expectedModCount。这就不会触发前面的异常报错了。
结论
所以这里就得出结论了,用iterator可以去remove,用for each就不行。因为本质上迭代器是构造一个集合的索引,改变对象同时要改变索引的指向,这才能按预期走下去。iterator会维护这种修改操作与迭代索引本身的一致性,而for each则不保证这个一致,所以在使用的时候需要确认场景。
很多语法糖是具有场景的,这些场景不应该只去死记它的特性更应该去分析它的原理与设计思想,这样才能掌握其思想并应用到实际中。