单线程也报 ConcurrentModificationException,我想了一晚上没想通
九月底做购物车清理,需求是把库存为 0 的商品从列表里剔掉。我写得很顺手:
for (CartItem item : cartItems) {
if (item.getStock() == 0) {
cartItems.remove(item);
}
}
单元测试里列表只有 3 个元素,跑通了。上线之后用户购物车里有两个失效商品时,接口 500:
java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)
at java.util.ArrayList$Itr.next(ArrayList.java:859)
at com.xxx.CartService.clean(CartService.java:63)
名字里带 Concurrent,我第一反应是多线程问题,但这段代码明明在一个同步方法里。查了一晚上才明白,这个名字是 JDK 的"历史遗留"——它检测的是结构修改,不一定是并发引起的。
为什么 3 个元素不报错,2 个才报错
先说这个诡异现象。看 JDK 8 里 ArrayList 的迭代器源码:
private class Itr implements Iterator<E> {
int cursor; // 下一个要返回的索引
int lastRet = -1;
int expectedModCount = modCount; // 创建迭代器时快照
public boolean hasNext() {
return cursor != size; // 注意:不是 cursor < size
}
public E next() {
checkForComodification();
// ...
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
hasNext() 判断的是 cursor != size,用的是等于号。删除元素后 size 变小,最后一次 next 可能被跳过,异常就藏起来了。
具体到我的例子,列表 [A, B, C],要删 B 和 C:
初始:size=3, modCount=3
第 1 轮:cursor=0,取 A,不删。cursor=1,hasNext: 1 != 3 为 true
第 2 轮:取 B(索引1),删掉。size=2, modCount=4。cursor=2
第 3 轮:hasNext: cursor(2) != size(2) ? false → 循环直接结束!
C 根本没被遍历到,循环"正常"退出了,异常被完美避开。所以我的测试数据恰好掩盖了 bug,还顺带漏删了商品。
如果列表是 [A, B],删 A:
初始:size=2, modCount=2
第 1 轮:cursor=0,取 A,删掉。size=1, modCount=3。cursor=1
第 2 轮:hasNext: 1 != 1 ? false → 结束,还是不报
那到底什么时候报?删掉倒数第二个元素时:
列表 [A, B, C],删 B:
第 1 轮:cursor=0 取 A。cursor=1
第 2 轮:取 B(索引1),删除。size=2,modCount=4。cursor=2
第 3 轮:hasNext: 2 != 2,false,结束。不报。
看来是 hasNext 恰好错过。换成 [A, B, C, D],删 A:
第 1 轮:cursor=0 取 A,删除。size=3,modCount=5。cursor=1
第 2 轮:hasNext: 1 != 3,true → next() → checkForComodification
modCount(5) != expectedModCount(4) → 抛异常
结论:只有删除的是倒数第二个元素时,循环会侥幸终止;其他情况几乎都会抛异常。这个巧合让 bug 具有很强的隐蔽性——测试时删一个元素不报错,就以为没问题了。
modCount 到底是个啥
它是 ArrayList 从 AbstractList 继承来的字段,记录结构性修改的次数。所谓结构性修改,就是改变 size 的操作:
public boolean add(E e) {
ensureCapacityInternal(size + 1);
elementData[size++] = e;
return true;
}
public E remove(int index) {
rangeCheck(index);
modCount++; // 每次 remove 加一
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index, numMoved);
elementData[--size] = null;
return oldValue;
}
而 set(int index, E element) 不改 size,也就不改 modCount,所以用 for-each 遍历时 set 是安全的。
这套机制叫 fail-fast(快速失败):不保证在所有并发修改场景下都报错,只是"尽最大努力"在发现不一致时立刻抛异常,避免在错误的状态上继续跑,产生更诡异的结果。JDK 的注释里写得很清楚,它是尽最大努力而非保证,不能拿它做并发控制的正确性依据。
四种正确写法,我全测了一遍
方案一:Iterator.remove(最标准)
Iterator<CartItem> it = cartItems.iterator();
while (it.hasNext()) {
CartItem item = it.next();
if (item.getStock() == 0) {
it.remove(); // 用迭代器自己的 remove
}
}
为什么这个就不报错?看 Itr.remove 的实现:
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet); // 底层 remove,modCount++
cursor = lastRet; // 游标回退
lastRet = -1;
expectedModCount = modCount; // 关键:同步回预期值
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
最后那行 expectedModCount = modCount 是关键。迭代器自己删的时候,会主动把预期值同步成新的 modCount,所以下次 check 能通过。
注意一个坑:每次 next() 之后只能调用一次 remove(),连续调两次会因为 lastRet < 0 抛 IllegalStateException。
方案二:removeIf(JDK 8,最简洁)
cartItems.removeIf(item -> item.getStock() == 0);
一行搞定。它内部实现并没有用迭代器,而是两趟遍历:第一趟用 BitSet 标记要删的元素,第二趟统一搬移:
// 简化后的核心逻辑
final BitSet removeSet = new BitSet(size);
for (int i = 0; i < size; i++) {
if (filter.test(elementAt(es, i))) removeSet.set(i);
}
// 后续批量 arraycopy,最后一次性 modCount += 删除总数
我把这段代码给师傅看,他说"这写法最不容易出错,能用就用"。我后来在项目里统一改成这个了。
方案三:倒序 for 循环(不用迭代器,最快)
for (int i = cartItems.size() - 1; i >= 0; i--) {
if (cartItems.get(i).getStock() == 0) {
cartItems.remove(i);
}
}
原理很简单:从后往前删,删掉的元素后面的元素已经处理完了,索引不会错位。正序删为什么会错位?删掉索引 1 之后,原来索引 2 的元素挪到索引 1,而 cursor 已经走到 2,那个元素就被跳过了。
这个方案完全绕过了迭代器,也就不存在 modCount 检查。
方案四:正序删除时手动回退索引(不推荐)
for (int i = 0; i < cartItems.size(); i++) {
if (cartItems.get(i).getStock() == 0) {
cartItems.remove(i);
i--; // 手动回退
}
}
能工作,但太容易写错,而且 ArrayList 的 remove(int) 是 O(n) 的 arraycopy,正序删会多做很多次搬移。我不建议用。
三种方案实测对比
10 万个元素,随机删除其中 30%,JDK 8u171,跑 5 次取平均:
| 方式 | 耗时 | 说明 |
|---|---|---|
| for-each + list.remove(错误写法) | 抛异常 | 不适用 |
| Iterator.remove | 9.2 ms | 每次都做边界检查和 modCount 检查 |
| removeIf | 6.8 ms | 最快,批量搬移 |
| 倒序 for | 7.5 ms | 接近 removeIf |
| 正序 for + i-- | 11.4 ms | 最慢且易错 |
数据量小的情况下差异可以忽略,选哪个主要看可读性。
多线程场景怎么办
前面说的都是单线程。如果真的有多个线程同时读写同一个 ArrayList,上面四种方案都不管用,fail-fast 只是尽量报错,不能保证正确性。三条路:
// 1. 加同步(写少读多时性能差)
List<String> list = Collections.synchronizedList(new ArrayList<>());
// 2. 写时复制(读极多、写极少,比如监听器列表)
List<String> list = new CopyOnWriteArrayList<>();
// 3. JDK 8 流式处理,中间不共享可变状态
List<CartItem> valid = cartItems.stream()
.filter(item -> item.getStock() > 0)
.collect(Collectors.toList());
CopyOnWriteArrayList 的迭代器是 fail-safe 的,它遍历的是创建迭代器那一刻的数组快照,所以遍历时修改不会抛 CME,代价是读到的可能是旧数据。第三种的思路其实更好——不原地改,而是生成一个新列表,我们购物车那块最后就是这么重构的。
小结
记三条:一是 for-each 本质是迭代器,里面不能调用 list 自己的 add/remove;二是删除善用 removeIf,JDK 8 之后这是最优解;三是 CME 虽然在单线程下抛得莫名其妙,但它其实救了我——如果不是它报错,我那个"漏删商品"的 bug 会一直潜伏到上线,用户会看到失效商品还留在购物车里,比 500 更难查。