for (Order o : orders) { orders.remove(o); } 抛出 ConcurrentModificationException,可代码明明是单线程。反过来,把 ArrayList 换成 CopyOnWriteArrayList 就不抛了,但删掉的元素在本次循环里照样被处理一遍。
这两件事说明:fail-fast 不是并发保护,fail-safe 也不是"更安全的并发",它们只是两种不同的迭代语义。先把职责和判定机制拆开,再谈选型。
1. Iterable 与 Iterator:可迭代 vs 迭代状态
Iterable 回答"能不能被遍历",Iterator 承载"遍历到哪儿了"。前者是集合的能力声明,后者是一次遍历的游标,状态属于迭代器而不是集合。
public interface Iterable<T> {
Iterator<T> iterator(); // JDK 8 起还有 forEach、spliterator
}
public interface Iterator<T> {
boolean hasNext();
T next();
default void remove() { throw new UnsupportedOperationException(); }
}
for-each 只是语法糖,编译后是标准的迭代器调用:
for (String s : list) {
System.out.println(s);
}
// 等价于
for (Iterator<String> it = list.iterator(); it.hasNext(); ) {
String s = it.next();
System.out.println(s);
}
| 角色 | 职责 | 关键点 |
|---|---|---|
Iterable |
声明"可遍历",产出迭代器 | Collection 全部实现它 |
Iterator |
保存游标、next/hasNext |
每个迭代器一份状态,可同时存在多个 |
forEach |
内部仍走迭代器 | 传 Consumer,不能删元素 |
Spliterator |
为并行流切分 | 有 trySplit,不代表线程安全 |
多个迭代器互不干扰,这一点常被误解成"集合线程安全":两个线程各自 iterator(),各自持有游标,但底层数组是同一个,一个线程 add 触发扩容时另一个线程的游标就指向了旧数组。
2. modCount 与 ConcurrentModificationException 的判定
ArrayList 用一个 modCount 记录结构性修改次数,迭代器在创建时把它抄进 expectedModCount,每次 next 前对账:
private class Itr implements Iterator<E> {
int cursor;
int lastRet = -1;
int expectedModCount = modCount;
public E next() {
checkForComodification();
// ... 取值、cursor++、lastRet = i
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
关键在"结构性修改"这个词。set(index, e) 改值不算,add/remove/clear 算:
| 操作 | 是否改 modCount |
迭代中执行的结果 |
|---|---|---|
list.set(i, e) |
否 | 不抛异常,本次遍历可能读到新值 |
list.add(e) |
是 | 下一次 next 抛 CME |
list.remove(i) |
是 | 下一次 next 抛 CME |
list.clear() |
是 | 下一次 next 抛 CME |
map.put(新 key, v) |
是(HashMap) |
迭代中抛 CME |
map.put(已有 key, v) |
否 | 不抛,值被替换 |
map.get(k) |
否 | 安全 |
所以 CME 与多线程没有必然关系——单线程在迭代中改集合同样会抛。名字里的 Concurrent 是历史命名,不是"并发才发生"。
3. 三种触发写法,与三种正确删除姿势
// 反例 1:for-each 里 remove —— 单线程也会抛
for (String s : list) {
if (s.startsWith("tmp")) list.remove(s);
}
// 反例 2:两个线程共用一个 ArrayList
new Thread(() -> list.add("x")).start(); // 主线程正在 for-each
// 反例 3:子列表视图与父列表共享 modCount
List<String> sub = list.subList(0, 3);
for (String s : sub) {
list.add("y"); // 父列表结构变化,子列表迭代器抛 CME
}
子列表视图这一条最隐蔽:subList 返回的是 SubList,它没有自己的 modCount,而是直接读父列表的。父列表增删后,子列表的迭代器立刻判定失效——这正是 subList 文档里"父列表结构修改后子列表行为未定义"的实现层原因。
三种正确姿势,按推荐度排序:
// 1. Iterator.remove —— 显式游标,语义最清楚
Iterator<String> it = list.iterator();
while (it.hasNext()) {
if (it.next().startsWith("tmp")) it.remove();
}
// 2. Collection.removeIf —— 一行搞定,内部安全
list.removeIf(s -> s.startsWith("tmp"));
// 3. 倒序索引 —— 只在 ArrayList 这类随机访问集合上成立
for (int i = list.size() - 1; i >= 0; i--) {
if (list.get(i).startsWith("tmp")) list.remove(i);
}
Iterator.remove 之所以安全,是因为它在删除后把 expectedModCount 重新同步为新的 modCount,游标也回退一格。removeIf 更彻底:ArrayList 重写了它,用 BitSet 标记待删位置、按索引搬迁,整个过程中不创建迭代器,也不做 modCount 对账,所以既不会 CME,也避免了逐个 System.arraycopy。
// ArrayList.removeIf 的实现思路(JDK 8+)
public boolean removeIf(Predicate<? super E> filter) {
final BitSet removeSet = new BitSet(size);
// 第一遍:按索引判断,记下要删的位置
// 第二遍:一次搬迁,压缩数组,modCount++
}
注意 removeIf 的默认实现(Collection 接口里)走的是 iterator.remove(),同样安全;但只有 ArrayList、HashMap 这类重写过的实现才有上面那套批量优化。
4. fail-safe:快照、弱一致与它们的代价
fail-safe 的迭代器不在原集合上读,而是在一份快照或一个弱一致视图上读,因此永不抛 CME,但也永远不保证"看到最新数据":
List<String> cow = new CopyOnWriteArrayList<>(List.of("a", "b"));
for (String s : cow) {
cow.add("c"); // 不抛异常
System.out.println(s); // 只打印 a、b
}
System.out.println(cow.size()); // 3,新元素下一轮才可见
ConcurrentHashMap<String, Integer> chm = new ConcurrentHashMap<>();
for (String k : chm.keySet()) {
chm.put("new", 1); // 不抛,但本次遍历可能看不到也可能看到
}
| 维度 | fail-fast(ArrayList、HashMap) |
fail-safe(CopyOnWriteArrayList、ConcurrentHashMap) |
|---|---|---|
| 迭代依据 | 原集合的实时状态 + modCount 对账 |
快照数组 / 弱一致视图 |
| 结构性修改 | 抛 CME | 不抛 |
| 数据新鲜度 | 实时(但在改之前就失效) | 快照:看不到;弱一致:可能看到 |
| 写代价 | O(1) 均摊 | 全量复制 O(n)(COW)/ 分段 CAS(CHM) |
| 内存峰值 | 低 | 写时翻倍 |
| 适用 | 单线程或读多写少但需实时 | 写极少、读极多(监听器列表、配置快照) |
CopyOnWriteArrayList 的"安全"是花钱买的:每次 add/remove 都复制整个数组,写多的时候 GC 会先崩。它的正当场景是"元素少、读极多、写几乎不发生",比如事件监听器注册表。ConcurrentHashMap 的迭代器则更微妙:它不复制,靠节点链表在并发下的形态保证不崩,但只能给"弱一致"——遍历期间的新增不保证可见,size() 也是估计值。
fail-fast 不等于线程不安全,fail-safe 不等于线程安全。 前者只是"发现被改就报错",后者只是"被改了也接着走"。真正决定线程安全的是集合本身有没有同步。
5. 排错:五个与迭代相关的现场
| 现象 | 根因 | 处理 |
|---|---|---|
| 单线程 for-each 里删除抛 CME | 直接调集合的 remove |
改 Iterator.remove 或 removeIf |
| 偶发 CME,日志里只有一条线程 | 另一个线程在改同一集合(或线程池复用) | 换并发集合或改为不可变 + 替换引用 |
subList 遍历报 CME,父列表没动过 |
父列表被别处结构修改 | 复制出独立列表 new ArrayList<>(sub) |
| COW 集合内存持续上涨 | 高频写触发全量复制 | 换 ConcurrentHashMap 或加写合并 |
| 遍历 COW 时新元素没被处理 | 迭代的是快照 | 改成"处理到空为止"的循环,或换代际标记 |
最后一条是设计问题而不是 bug:CopyOnWriteArrayList 语义上就是"本次遍历看到的是开始那一刻的内容",需要"边加边处理"的场景说明数据结构选错了。
6. 边界:不可变集合与视图的陷阱
Arrays.asList、List.of、Collections.unmodifiableList 三者常被混为一谈,行为差别不小:
List<String> fixed = Arrays.asList("a", "b");
fixed.set(0, "z"); // 允许:底层就是数组
fixed.add("c"); // UnsupportedOperationException:固定长度
List<String> immutable = List.of("a", "b");
immutable.set(0, "z");// UnsupportedOperationException:完全不可变
List<String> view = Collections.unmodifiableList(new ArrayList<>(List.of("a")));
// 视图本身不可改,但原列表改了它会跟着变
| 结构 | 能否 set |
能否 add/remove |
底层是否共享 |
|---|---|---|---|
Arrays.asList(...) |
可以 | 不可以(固定长度) | 共享传入数组 |
List.of(...) / Set.of(...) |
不可以 | 不可以 | 内部持有,不可变 |
Collections.unmodifiableList(list) |
不可以 | 不可以 | 共享原列表,原列表可变 |
List.copyOf(list) |
不可以 | 不可以 | 复制一份,与原列表脱钩 |
subList(a, b) |
可以 | 可以(改的是父列表) | 共享父列表结构 |
List.of 的不可变性还带来一个副作用:它的迭代器不会抛 CME(因为根本改不了),所以"用 List.of 就不会有 CME"这种结论只在"没人能改"的前提下成立。
7. 自测
for-each编译后等价于哪几行代码?为什么不能在循环体里调集合的remove?modCount记录的是什么?list.set(i, e)为什么不触发 CME?- 单线程会不会抛
ConcurrentModificationException?举一个具体写法。 Iterator.remove与removeIf各自为什么安全?后者在ArrayList里额外省掉了什么?CopyOnWriteArrayList的迭代器看到的是什么?写操作付出的代价是什么?Arrays.asList与List.of在set和add上的行为差别,分别由什么机制造成?