2311 字
约 7 分钟
16
S04-X13 · Iterator 与 Iterable:fail-fast 和 fail-safe 的真实差别

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. 自测

  1. for-each 编译后等价于哪几行代码?为什么不能在循环体里调集合的 remove?
  2. modCount 记录的是什么?list.set(i, e) 为什么不触发 CME?
  3. 单线程会不会抛 ConcurrentModificationException?举一个具体写法。
  4. Iterator.remove 与 removeIf 各自为什么安全?后者在 ArrayList 里额外省掉了什么?
  5. CopyOnWriteArrayList 的迭代器看到的是什么?写操作付出的代价是什么?
  6. Arrays.asList 与 List.of 在 set 和 add 上的行为差别,分别由什么机制造成?
S04-X13 · Iterator 与 Iterable:fail-fast 和 fail-safe 的真实差别
https://www.clxhxhhr.top/posts/5759/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。