list的迭代安全与 fail-fast

56 阅读4分钟

1. 名词与本质

  • 迭代安全(iteration safety) :在遍历期间,容器的结构是否允许被修改,以及在多线程读写时,遍历能否保持一致的语义(不崩不乱)。

  • fail-fast 迭代器:当检测到“结构性修改”(结构上增/删元素)且不是由当前迭代器执行时,在下次 next()/hasNext() 处抛 ConcurrentModificationException(CME)。这是“尽早发现问题”的调试信号不是并发安全保证

典型 fail-fast 容器:ArrayList、LinkedList、HashMap…

典型 非 fail-fast(或 弱一致/快照)容器:CopyOnWriteArrayList(快照)、ConcurrentLinkedQueue、ConcurrentHashMap 的迭代器(弱一致)。

2. fail-fast 的实现原理(以 ArrayList/LinkedList 为例)

  • 每个容器维护一个 modCount(结构修改计数)。

  • 迭代器创建时保存 expectedModCount = modCount

  • 每次 next()/hasNext()/forEachRemaining() 会校验:若 modCount != expectedModCount → 抛 CME。

  • 例外:当前迭代器自己的 remove()/ListIterator.add()/set() 会同步更新 expectedModCount,因此允许在遍历中安全地修改

结构性修改 指改变元素数量或内部结构(增/删/清空等);set(i, x) 不改变结构,不算结构修改。

3. 最常见“踩坑”与修复

坑 1:for-each 中直接对 List 做增删

// ❌ 可能 CME
for (String s : list) {
  if (s.startsWith("x")) list.remove(s);
}

修复:用迭代器的 remove() 或 removeIf(JDK8+ 内部使用迭代器语义)

// ✅ 方式 A:显式迭代器
for (Iterator<String> it = list.iterator(); it.hasNext(); ) {
  if (it.next().startsWith("x")) it.remove(); // 安全,更新 expectedModCount
}

// ✅ 方式 B:批量删除(更简洁)
list.removeIf(s -> s.startsWith("x"));

坑 2:subList 与原 List 交叉修改

List<Integer> sub = list.subList(10, 20);
sub.clear();     // OK(通过 subList 修改,modCount同步)
list.add(999);   // ❌ 再访问 sub 迭代器 → CME

修复:要么只修改 subList,要么用独立副本

List<Integer> slice = new ArrayList<>(list.subList(10, 20)); // 快照

坑 3:Collections.synchronizedList 遍历未加锁

List<Integer> sync = Collections.synchronizedList(new ArrayList<>());
for (Integer x : sync) {   // ❌ 只锁了结构操作,遍历仍需外部同步
  ...
}

修复:文档要求遍历时也需同步同一把锁

synchronized (sync) {
  for (Integer x : sync) { ... }
}

坑 4:多线程写 + fail-fast****

  • fail-fast 不是并发控制,它只是“尽力”报警;未同步的并发修改可能看不到从而不抛,导致脏读/遗漏。

  • 正确做法:用并发容器(弱一致)或外部同步/串行化。

4. 正确改法清单(单线程遍历)

  • 单线程遍历中需要删:用 Iterator.remove()ListIterator 的 add/remove/set。

  • 大量条件删除:removeIf(底层走迭代器,安全且可读)。

  • 需要边遍历边插入到中间:ListIterator.add() (只能对当前位置附近,且复杂度 O(n))。

5. 跨线程场景的三条道路

  1. 外部同步(强一致)

    • 一把锁保护“遍历 + 修改”:
List<T> list = Collections.synchronizedList(new ArrayList<>());
synchronized (list) {
  for (T t : list) { ... }      // 读
  list.add(x); list.remove(y);   // 写
}
    • 优点:一致性强;缺点:并发度低,容易形成“大锁”。

  1. 快照/副本遍历(读多写少)

    • CopyOnWriteArrayList:遍历走旧数组快照,不抛 CME;写时复制开销大,适合监听器列表
CopyOnWriteArrayList<Listener> ls = new CopyOnWriteArrayList<>();
for (Listener l : ls) l.onEvent(e);   // 永不 CME
    • 或者遍历前自己做快照:

for (T t : new ArrayList<>(list)) { ... } // 读快照,写原表互不干扰
  1. 并发容器(弱一致) (读写并行)

    • 例如 ConcurrentLinkedQueue、ConcurrentHashMap 的迭代器弱一致:不抛 CME,能看到遍历开始后的一些改动,也可能看不到全部改动;按需保证即可。

    • 注意:JDK 没有“并发 List”的理想实现;队列/栈语义尽量改数据结构(如 ConcurrentLinkedQueue)或用消息/事件流替代共享 List

6. Kotlin 小贴士

  • Kotlin MutableList 默认实现仍是 java.util.ArrayList(fail-fast 规则完全相同)。
  • forEach {} / for (e in list) 背后也是迭代器;在 lambda 中直接 list.remove(e) 仍会 CME
  • 需要删就用:
val it = list.iterator()
while (it.hasNext()) if (needRemove(it.next())) it.remove()
// 或
list.removeAll { needRemove(it) }

7. 选择建议(面试/实战可直接背)

  • 业务列表:默认 ArrayList;遍历删用 removeIf/迭代器;避免 subList 与父表交叉改。

  • 队列/栈:ArrayDeque,无 fail-fast 问题(不支持中间插入/下标)。

  • 监听器/读多写少:CopyOnWriteArrayList(快照、不抛 CME)。

  • 多线程同时读写且需吞吐:改为并发容器或消息队列;不要指望 fail-fast 解决并发安全。

  • 需要强一致:synchronizedList + 同锁遍历。

8. 小抄(TL;DR)

  • fail-fast = 发现“非本迭代器的结构修改”就抛 CME;只是早失败机制非并发控制

  • 在遍历中修改,用迭代器提供的改法(remove/ListIterator.add);或用 removeIf。

  • 多线程遍历:要么加锁,要么用快照,要么换并发容器/消息化

  • subList 易踩坑:修改父表后再用子表迭代 → CME;需要就用独立副本