首页 / Java 学习笔记 / 09

JAVA · Vol.I · DAY 09 · 集合框架源码

Iterator 与 fail-fast:集合遍历的安全边界

中级中高#集合#机制
第 1 站

凌晨 2 点的 ConcurrentModificationException

凌晨 2:17,生产环境的告警钉钉把值班同学从床上炸醒。打开日志一看,满屏都是同一个异常:

production.log · 事故现场
2024-03-15 02:17:33 ERROR OrderSyncService - sync failed
java.util.ConcurrentModificationException
    at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1013)
    at java.util.ArrayList$Itr.next(ArrayList.java:965)
    at com.biz.service.OrderSyncService.processOrders(OrderSyncService.java:87)
    ...

代码逻辑很简单——线程 A 在遍历一个共享的订单列表做批量同步,线程 B 在消费完订单后调用 list.remove() 清理已处理项。两套逻辑各自正确,但一旦并发执行,一个在读、一个在改,Iterator 就会毫不留情地抛出 ConcurrentModificationException(简称 CME)。

"为什么不能一边遍历一边删?Iterator 是怎么发现集合被改了的?fail-fast 和 fail-safe 到底有什么区别?" —— 面试官通过这一个问题,就能判断你对 Java 集合框架的遍历安全机制理解到什么程度。

很多同学的回答停留在"modCount 不一致就抛异常"就说不下去了。但高级工程师需要回答的是:

  • modCount 在哪个层级定义?哪些操作会触发它递增?哪些操作不会?
  • fail-fast 是"一定能检测出并发修改"还是"尽力而为"?哪些场景下它检测不到
  • Iterator.remove() 为什么安全?removeIf() 内部做了什么,为什么它自己遍历自己删却不抛 CME?
  • 增强 for 循环的本质是什么?CopyOnWriteArrayList 的快照语义又有什么代价?

带着这些问题,我们从 Iterator 的设计模式源头开始,逐层拆解到方法级源码。

第 2 站

Iterator 设计模式:统一遍历的抽象之美

Iterator 模式是 GoF《设计模式》中的经典行为模式,核心思想是:将集合的遍历逻辑从集合本身中抽离出来,封装到一个独立的迭代器对象中。这样,集合类只需关心数据存储,遍历职责完全交给 Iterator。

Java 的 java.util.Iterator 接口是这个模式的标准实现,定义极其精简:

Iterator.java · JDK 接口定义
public interface Iterator<E> {

    // 是否还有下一个元素
    boolean hasNext();

    // 返回下一个元素,并将游标后移
    E next();

    // 移除上一次 next() 返回的元素(default 方法抛 UnsupportedOperationException)
    default void remove() {
        throw new UnsupportedOperationException("remove");
    }

    // Java 8 引入:对剩余元素执行 action
    default void forEachRemaining(Consumer<? super E> action) {
        Objects.requireNonNull(action);
        while (hasNext())
            action.accept(next());
    }
}

有了这个接口,不管底层是数组、链表还是红黑树,调用者都可以用统一的方式遍历:

Demo.java · 统一遍历的威力
// 以下两段代码结构完全一致,尽管底层数据结构截然不同

List<String> list = new ArrayList<>(List.of("A", "B", "C"));
Set<String>  set  = new HashSet<>(List.of("A", "B", "C"));

// 遍历 ArrayList —— 底层是数组 + 索引
Iterator<String> it1 = list.iterator();
while (it1.hasNext()) {
    System.out.println(it1.next());
}

// 遍历 HashSet —— 底层是哈希表
Iterator<String> it2 = set.iterator();
while (it2.hasNext()) {
    System.out.println(it2.next());
}

而增强 for 循环(foreach)在编译期会被自动翻译为 Iterator 遍历(下一站我们会用 javap 反编译验证)。这意味着你写的每一行 for (T item : collection),背后都在使用 Iterator 模式。

Iterator 内部状态机 初始状态 cursor=0, lastRet=-1 hasNext()=true next() 返回 cursor++, lastRet=cursor-1 hasNext()=true next() 返回 cursor++, lastRet更新 remove() 安全删除 expectedModCount 同步 hasNext()=false 遍历结束 cursor == size 关键约束 remove() 只能在 next() 之后调用一次,且内部会同步 expectedModCount = modCount
图 1Iterator 状态机:cursor 和 lastRet 驱动遍历推进,remove() 后同步 modCount 保证安全
为什么需要统一的 Iterator 接口?

如果没有 Iterator,调用者需要了解每种集合的内部结构:ArrayList 用索引遍历、LinkedList 用节点跳转、HashMap 要遍历桶数组 + 链表。Iterator 把这些差异封装了,让算法可以独立于数据结构编写。这也是为什么 Java 8 的 Stream API 可以在任意 Collection 上工作——它底层依赖的就是 Spliterator(Iterator 的并行增强版)。顺带一提:ListIterator 是 Iterator 的 List 专用扩展,额外提供 hasPrevious()/previous()/add()/set(),支持双向遍历与在迭代中插入元素。

第 3 站

modCount:集合的"修改版本号"

fail-fast 机制的核心是一个计数器:modCount。它定义在 AbstractListHashMap 则在自身类中重新声明了一个同名字段)中,记录集合被结构性修改的次数。

AbstractList.java · modCount 定义
public abstract class AbstractList<E> extends AbstractCollection<E>
    implements List<E> {

    // 结构性修改的计数:add, remove, clear 等操作每次 +1
    // 注意:它是 protected 的,子类可以直接访问
    protected transient int modCount = 0;
}

什么算"结构性修改"?

一句话:任何改变了集合 size(元素个数)的操作都是结构性修改。注意 set()(替换指定位置的值)不算——它不改 size,因此遍历时调用 list.set(i, newValue) 不会触发 CME:

操作 是否结构性修改 modCount 变化
add() / remove() / clear() +1(每次)
addAll() / removeAll() / retainAll() +1(批量操作同样递增)
set(index, value) 不变
get() / contains() / iterator() 否(只读) 不变
为什么 set() 不算结构性修改?

因为 fail-fast 保护的是"遍历游标的合法性":add/remove/clear 会改变元素下标与 size,导致游标指向的元素错位或越界,必须报错;而 set 只改值不改结构,cursor 与 size 的语义完全不变,读到的仍然是合法元素。所以 set 不动 modCount,遍历中替换值永远安全。

来看 ArrayList 中 add()remove() 是怎么操作 modCount 的(JDK 8 版本,JDK 11+ 只是把 modCount++ 挪进了 public 的 add,结论不变):

ArrayList.java · modCount 的递增时机
// add 方法 —— 每次成功添加都让 modCount +1
public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // 内部无条件 modCount++
    elementData[size++] = e;
    return true;
}

private void ensureExplicitCapacity(int minCapacity) {
    modCount++;                    // ★ 无条件递增:每次 add 都 +1
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);         // 真正扩容:newCap = oldCap + (oldCap >> 1),即 1.5 倍
}

// remove(int index) —— 同样 modCount +1
public E remove(int index) {
    rangeCheck(index);
    modCount++;                    // ← 结构性修改,+1
    E oldValue = elementData(index);
    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index + 1, elementData, index, numMoved);
    elementData[--size] = null;  // 尾部置空,帮助 GC
    return oldValue;
}
深度点:不是"扩容才 +1",而是"每次 add 都 +1"

网上很多资料说"扩容算结构性修改,所以 modCount++",这个说法不够精确。modCount++ 写在 ensureExplicitCapacity 的第一行,是无条件执行的——即使数组容量足够、根本没触发 grow(),这次 add 依然 +1。扩容只是恰好发生在某次 add 的内部。理解了这一点,你就知道为什么"扩容 1.5 倍 / 2 倍"这类阈值与 fail-fast 的检测粒度无关:fail-fast 关心的是"结构变了多少次",而不是"内存搬了多少"。

为什么 modCount 不需要 synchronized?

因为 fail-fast 本身就不是为线程安全设计的。modCount 是一个"尽力而为"的检测机制,它主要防范的是单线程下的错误用法(比如在 foreach 里调 list.remove())。多线程场景下,即使 modCount 碰巧相等,也不代表线程安全——fail-fast 的 Javadoc 里明确写了"不保证检测到并发修改"。

第 4 站

Itr 源码拆解:fail-fast 全链路

ArrayList.iterator() 返回的是一个名为 Itr内部类实例(日志里那个 ArrayList$Itr 就是它)。它只有三个字段、三个方法,却承载了全部检测逻辑:

ArrayList.java · Itr 内部类完整源码(JDK 8)
private class Itr implements Iterator<E> {
    int cursor = 0;                 // 下一个要返回的元素下标
    int lastRet = -1;               // 上一次 next() 返回的元素下标,-1 表示"没有"
    int expectedModCount = modCount; // ★ 创建时对 modCount 拍快照

    public boolean hasNext() {
        return cursor != size;       // 注意:不校验 modCount!
    }

    public E next() {
        checkForComodification();       // ★ 第一行就校验版本号
        int i = cursor;
        if (i >= size)
            throw new NoSuchElementException();
        Object[] elementData = ArrayList.this.elementData;
        if (i >= elementData.length)  // 数组长度都小于 size 了 → 集合被删过
            throw new ConcurrentModificationException();
        cursor = i + 1;
        return (E) elementData[lastRet = i];
    }

    final void checkForComodification() {
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }
}

全链路走一遍:从创建到 BOOM

假设 ArrayList 已有 3 个元素,经历过 3 次 add,所以 modCount = 3

FailFastDemo.java · 完整的异常触发过程
List<String> list = new ArrayList<>();
list.add("A");  // modCount = 1
list.add("B");  // modCount = 2
list.add("C");  // modCount = 3

Iterator<String> it = list.iterator();
// ↑ Itr 构造,expectedModCount = 3(快照)

while (it.hasNext()) {
    String s = it.next();   // 第一次调用 next():checkForComodification() 通过
    if ("B".equals(s)) {
        list.remove(s);     // ← 用 list 的 remove()!modCount 变成 4
    }
    // 第二次调用 it.next():
    //   checkForComodification() 发现 modCount(4) != expectedModCount(3)
    //   → 抛出 ConcurrentModificationException!
}
fail-fast 异常触发时序 ArrayList Iterator add("A"), add("B"), add("C") modCount = 3 iterator() expected = 3 checkForComodification() 3 == 3 ✓ next() → "A" checkForComodification() 3 == 3 ✓ next() → "B" list.remove("B") modCount = 4 checkForComodification() 4 != 3 ✗ ConcurrentModificationException modCount(4) != expectedModCount(3) 关键:检测发生在 it.next() 内部,而非 list.remove() 时刻
图 2fail-fast 异常触发时序:list.remove() 修改 modCount 后,下一次 it.next() 的 checkForComodification() 检测到不一致并抛出 CME

从源码可以提炼出三个"细节级"结论,面试时说出来就是加分项:

  • 异常不是在 list.remove() 那一刻抛出的——而是在下一次调用 it.next() 时才检测到;checkForComodification() 位于 next()第一行,先于任何业务逻辑。
  • hasNext() 只是 cursor != size从不校验 modCount——这是后面"删最后一个元素可能不抛"这类盲区的根源。
  • next() 里还有一道"双保险":i >= elementData.length 也会抛 CME。它专门捕获"集合被删得比 size 还快"的极端竞态。
fail-fast 是"一定能检测到并发修改"吗?

不是。JDK 文档明确说了:fail-fast 是一种尽力而为(best-effort)的机制,不能保证 100% 检测。例如 modCount 是 int 类型,极端情况下可能溢出回到原来的值;或者在 Iterator 的两次操作之间集合被修改了偶数次,modCount 碰巧又等于 expectedModCount。所以 fail-fast 只用于检测bug,不能依赖它来实现业务逻辑。
第 5 站

增强 for 的本质:编译器替你写的 Iterator 代码

增强 for 是语法糖。它的"真身"是什么?用 javap -c 反编译一个最简单的类就能看到真相:

javap -c Demo.class · foreach 反编译结果(节选)
// 源:for (String s : list) { System.out.println(s); }
0:  aload_1
1:  invokeinterface #2  java/util/List.iterator()Ljava/util/Iterator;
6:  astore_2              // 编译器悄悄把迭代器塞进局部变量表
7:  aload_2
8:  invokeinterface #3  java/util/Iterator.hasNext()Z
13: ifeq 36             // hasNext() == false 就跳出循环
16: aload_2
17: invokeinterface #4  java/util/Iterator.next()Ljava/lang/Object;
22: checkcast #5            // 向下转型回 String
25: astore_3                // 存入循环变量 s
26: getstatic #6            // System.out
29: aload_3
30: invokevirtual #7        // println(s)
33: goto 7               // 回到 hasNext() 判断
36: return

字节码揭示了三个关键事实:

  • foreach 就是 Iterator 循环:`iterator()` → `hasNext()` → `next()` 三步循环,与手写迭代器完全等价,因此同样受 fail-fast 保护。
  • 迭代器引用被编译器藏起来了:它存在局部变量表(astore_2)里,但源码层面你拿不到这个引用——这就是"foreach 里无法调用 it.remove()"的根本原因,不是语法禁止,而是你根本没机会拿到它。
  • 数组是特例:对数组做 foreach,编译器生成的是普通下标循环(int i = 0; i < arr.length; i++),数组没有 modCount,自然也没有 fail-fast 概念。

四种遍历方式的对比

遍历方式 编译产物 性能特征 遍历中安全删除
数组增强 for 下标循环 快(无对象分配) 无 fail-fast 概念,随意改
集合增强 for Iterator(hasNext/next) 中(有迭代器对象) 不能(拿不到迭代器引用)
普通 for + get(i) 下标循环 ArrayList 略快;LinkedList 是 O(n²) 灾难 能(倒序删除)
显式 Iterator 同增强 for 能(it.remove()
Stream / removeIf Spliterator 中(可并行) 推荐(removeIf 批量删除)
"遍历 ArrayList 用普通 for 还是增强 for 更快?"

ArrayList 场景下普通 for + get(i) 略快——少一次迭代器对象分配,且 get(i) 直接下标访问;增强 for 多一层 hasNext/next 调用。但这点差距在 O(1) 级别的访问开销面前微乎其微。真正的性能陷阱是:LinkedList 用普通 for + get(i),每次 get 都要从头遍历,整体 O(n²),这才会被打爆。结论:优先可读性,用增强 for;要遍历中删除,显式 Iterator 或 removeIf。
面试追问:"foreach 里到底能不能删?"

标准答法分三层:① 不能调 list.remove()——会 CME(单线程也一样);② 也调不了 it.remove()——编译器生成的迭代器引用被藏在局部变量表里,源码拿不到;③ 正确姿势是改成显式 Iterator 循环调 it.remove(),或直接用 Java 8 的 removeIf()。能一口气答出这三层,说明你真的看过字节码。

第 6 站

Iterator.remove():为什么它是唯一的安全删除通道

同样的 remove,走 list.remove() 抛 CME,走 it.remove() 就没事。差异全在 Itr.remove() 的源码里——它多做了三件事:

ArrayList.java · Itr.remove() 完整源码(JDK 8)
public void remove() {
    if (lastRet < 0)                    // ① 还没 next() 就 remove()?
        throw new IllegalStateException();
    checkForComodification();              // ② 先做 fail-fast 校验

    try {
        ArrayList.this.remove(lastRet); // ③ 删"上一次 next() 返回的元素"(内部 modCount++)
        cursor = lastRet;                  // ④ 游标回退到被删位置,避免跳过下一个元素
        lastRet = -1;                   // ⑤ 一次 next 只能配一次 remove
        expectedModCount = modCount;       // ★ ⑥ 同步版本号——"安全"的关键
    } catch (IndexOutOfBoundsException ex) {
        throw new ConcurrentModificationException();
    }
}

核心就一句话:it.remove() 在删完之后把 expectedModCount = modCount 同步了。版本号两边一起涨,下一次 next() 的校验自然通过。而 list.remove() 只涨 modCount、不动你的 expectedModCount,两边立刻失衡。

一次 remove 的完整内部账本

SafeRemove.java · 游标账本追踪
List<String> list = new ArrayList<>(List.of("A", "B", "C"));
Iterator<String> it = list.iterator();  // cursor=0, lastRet=-1, expected=3

it.next();  // → "A",cursor=1, lastRet=0
it.next();  // → "B",cursor=2, lastRet=1

it.remove(); // 内部:remove(1) → 数组变为 [A, C],size=2,modCount=4
             //       cursor=1, lastRet=-1, expectedModCount=4(同步!)

it.next();  // → "C":checkForComodification 4==4 通过;cursor=2
// 结果:A、C 都被遍历到——没有漏元素,也没有抛异常
"it.remove() 之后还能继续遍历吗?会不会漏掉被删元素后面的元素?"

不会漏。删除后 cursor = lastRet 回退到被删位置,而删除动作让后面的元素整体左移一位填补空位,所以下一次 next() 返回的恰好就是"原被删元素后面的那个"。cursor 回退 + 数组左移,两者抵消,遍历语义完美连续。

边界条件:IllegalStateException 的两道闸

IllegalState.java · remove() 的两条红线
Iterator<String> it = list.iterator();

it.remove();   // ❌ IllegalStateException:还没 next(),lastRet == -1

// 或:
it.next();     // → "A"
it.remove();   // 删 A,lastRet 置回 -1
it.remove();   // ❌ IllegalStateException:一次 next() 只能配一次 remove()

list.remove() vs it.remove():一表看懂

维度 list.remove(i / o) it.remove()
谁能调用 任何持有 list 引用的人 只有迭代器自己
对已创建迭代器的影响 全部失效(modCount +1 但不同步) 只同步自己的 expectedModCount,自身继续有效
前置条件 下标合法即可 必须先 next()(lastRet >= 0),否则 IllegalStateException
删除对象 任意下标 / 任意元素 只能删"上一次 next() 返回的元素"
连续调用 可以 第二次抛 IllegalStateException
安全删除姿势(面试标准答案)
  • 遍历中要删元素:用 iterator.remove()removeIf(),绝不用集合自身的 remove
  • 需要倒序遍历删除:普通 for 从 size-1 往 0 倒着删,同样安全(删过的下标不再被访问)
  • 不要在 foreach 里碰运气:即使"这次没抛",也是侥幸,下一个元素就可能炸
第 7 站

JDK 8 removeIf:一趟批量删除,如何避免"自误报"

Java 8 给 Collection 加了一个 default 方法 removeIf(Predicate),一行搞定"遍历 + 条件删除"。它最反直觉的地方是:它自己明明也在"遍历中删除",却不抛 CME。秘密要看两层实现。

第一层:Collection 的默认实现(LinkedList / HashSet 走这里)

Collection.java · removeIf 默认实现(JDK 8)
default boolean removeIf(Predicate<? super E> filter) {
    Objects.requireNonNull(filter);
    boolean removed = false;
    final Iterator<E> each = iterator();   // 拿到自己的迭代器
    while (each.hasNext()) {
        if (filter.test(each.next())) {
            each.remove();          // 用迭代器删 → expectedModCount 自动同步
            removed = true;
        }
    }
    return removed;
}

默认实现就是"手写迭代器循环"的封装——它删的是自己的迭代器,每次删除都同步 expectedModCount,所以全程无 CME。这就是为什么 LinkedListHashSetremoveIf 也安全。

第二层:ArrayList 的覆写——BitSet 批量删除

ArrayList 嫌弃逐条删太慢(每次删除都要 System.arraycopy 搬移后续元素,删 m 个最坏 O(n·m)),于是在 JDK 8 覆写了 removeIf,改成"一趟扫描 + BitSet 标记 + 原地压缩":

ArrayList.java · removeIf 覆写(JDK 8,结构与行为一致)
public boolean removeIf(Predicate<? super E> filter) {
    Objects.requireNonNull(filter);
    final int removeCount;
    final BitSet removeSet;
    final Object[] es = elementData;
    final int size = this.size;
    final int expectedModCount = modCount;
    removeSet = new BitSet(size);          // ① BitSet 记录"要删的下标"

    // ② 一趟扫描:循环条件里就盯着 modCount,被并发改了就提前退出
    for (int i = 0; modCount == expectedModCount && i < size; i++) {
        final E element = (E) es[i];
        if (filter.test(element)) {
            removeSet.set(i);
        }
    }
    if (modCount != expectedModCount)       // ③ 扫描期被并发修改 → 抛 CME(列表还没动)
        throw new ConcurrentModificationException();

    removeCount = removeSet.cardinality();
    if (removeCount > 0) {
        final int newSize = size - removeCount;
        // ④ 双指针左移压缩:把"不删的"依次搬到前面
        for (int i = 0, j = 0; i < size && j < newSize; i++) {
            if (!removeSet.get(i)) {
                es[j++] = es[i];
            }
        }
        // ⑤ 尾部置空,交给 GC
        for (int i = newSize; i < size; i++) {
            es[i] = null;
        }
        this.size = newSize;
        if (modCount != expectedModCount)     // ⑥ 压缩前再校验一次
            throw new ConcurrentModificationException();
        modCount++;                              // ⑦ 整个操作只 +1 一次!
    }
    return removeCount > 0;
}
removeIf:一趟扫描 + BitSet 标记 + 原地压缩 ① 原数组(size=6) A B C D E F filter.test() 一趟扫描,命中即 set(i) ② BitSet 标记(1 = 要删) 0 1 0 1 0 0 cardinality()=2 → 双指针左移压缩 ③ 结果(size=4) A C E F modCount 只 +1 一次 整个批量删除算"一次"结构性修改 → 自身永远不会"遍历中修改"误报 CME
图 3ArrayList.removeIf 的 BitSet 批量删除:一趟扫描标记下标,双指针原地压缩,modCount 只递增一次
"removeIf 自己遍历自己删,为什么不抛 CME?"

三个原因叠在一起:① 它根本不创建 Iterator,而是直接按下标扫描底层数组,不存在"别人的 expectedModCount";② 删除不是逐条进行的,而是先记下所有要删的下标,最后一次性原地压缩,modCount 只 +1 一次——整个操作对版本号来说只算一次结构性修改;③ 扫描期间若真被并发修改,循环条件 modCount == expectedModCount 会提前退出并抛 CME,绝不会把列表改坏一半再继续。

removeIf 不是"免疫",只是"自己不误报"

Misuse.java · removeIf 与既有迭代器的关系
Iterator<String> it = list.iterator();
list.removeIf(s -> s.startsWith("tmp"));  // ✅ 自身安全,但 modCount +1 了
it.next();   // ❌ CME!你手上这个迭代器的 expectedModCount 已过期

// 结论:removeIf 只保证"自己这一趟"不出事,
//      任何其他正在进行的遍历,都会被它的一次 modCount++ 打断
生产级对比:removeIf vs 手动 iterator.remove vs 谓词异常

① 性能:n 个元素删 m 个,逐条 iterator.remove() 最坏 O(n·m)(每次删除都搬移后续元素);removeIf 一趟 O(n)。100 万元素删 50 万,量级天壤之别。② 异常安全性:ArrayList 的覆写版本里,如果谓词在扫描期抛异常,列表尚未开始压缩,处于"原封不动"状态;而 Collection 默认实现是边遍历边删,谓词抛异常时可能已删了一部分。③ HashMap 的 keySet()/entrySet()/values() 在 JDK 8 也覆写了 removeIf,直接扫桶数组删除,同样安全高效。

removeIf 使用要点
  • 单线程批量条件删除,无脑用 removeIf:一行代码、O(n)、不误报 CME
  • Map 过滤:用 map.entrySet().removeIf(...)map.values().removeIf(...),不要手写遍历再 put
  • 需要边删边做业务逻辑(记日志、发消息):才退回显式 Iterator 循环
第 8 站

fail-safe:CopyOnWriteArrayList 的写时复制快照

如果业务确实需要"一边遍历一边被别人改"且不抛异常,就要换并发集合了。JDK 里最典型的 fail-safe(更准确的说法是弱一致性 / 快照一致性)集合是 CopyOnWriteArrayList(COW)。

CopyOnWriteArrayList.java · 写时复制核心(JDK 8)
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess {

    // 底层数组:volatile 保证跨线程可见性;没有任何 modCount
    private transient volatile Object[] array;

    // add:加锁 → 复制整数组 → 追加 → 原子替换引用
    public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len + 1); // ← 全量复制
            newElements[len] = e;
            setArray(newElements);     // volatile 写,原子替换引用
            return true;
        } finally {
            lock.unlock();
        }
    }

    // iterator:直接把"当前数组引用"交给迭代器——这就是快照
    public Iterator<E> iterator() {
        return new COWIterator<>(getArray(), 0);
    }
}

核心原理:每次写操作都复制整个底层数组,在新副本上修改,然后通过 volatile 引用一次性原子切换。Iterator 在创建时握住的只是"当时那个数组的引用",此后任何写操作都在新数组上进行,旧数组没人再动——快照天然不可变,因此根本不需要 modCount,也永远不会 CME

写时复制:迭代器永远握着"旧数组"这张快照 volatile array(旧数组:L1 L2 L3) Iterator 快照 → 旧数组(无 modCount) 后续遍历只读 snapshot,谁也影响不了它 新数组:L1 L2 L3 L4(复制后追加) Arrays.copyOf 全量复制 setArray(newElements) 原子替换引用 写:ReentrantLock 加锁 + 复制整数组;读:无锁直接读 volatile 代价:每次写 O(n);遍历中发起的写不影响进行中的 Iterator,但 Iterator 也看不到新数据
图 4CopyOnWriteArrayList 写时复制:写操作复制新数组并原子替换引用,旧数组被 Iterator 当作快照继续使用

快照的代价:COWIterator 连 remove 都不支持

COWIterator.java · 快照迭代器是只读的
static final class COWIterator<E> implements ListIterator<E> {
    private final Object[] snapshot;   // ★ 快照:创建时固定,之后不可变
    private int cursor;

    public E next() {
        if (!hasNext()) throw new NoSuchElementException();
        return (E) snapshot[cursor++];
    }

    public void remove() {
        throw new UnsupportedOperationException();  // ★ 快照是只读的!
    }
}

// 想删?通过集合自身删(会再复制一次),正在进行的遍历不受影响:
cowList.remove("B");   // 旧快照里 B 还在——遍历结果可能"过期"
"COW 的 Iterator 为什么连 remove 都不给?"

因为它遍历的是创建时刻的数组快照。快照是共享的、只读的,任何"就地删除"都会破坏其他正在使用同一快照的迭代器。要改数据,只能通过集合自身(复制新数组),这正是"以空间换一致性"的代价。记住:COW 的迭代器是只读的,想要"遍历中删"请改用 list.removeIf()(它会基于当前快照创建新数组)。

适用场景与代价

维度 ArrayList CopyOnWriteArrayList
写操作 O(1) 摊还 O(n):每次写都复制整数组
读 / 遍历 O(1),无锁 O(1),无锁,且永不 CME
遍历中别人修改 抛 CME 不抛;但也看不到新数据(快照)
内存 高:写瞬间新旧两份数组并存
典型场景 单线程 / 读改写均衡 读多写极少:监听器注册表、订阅者列表、黑白名单
为什么说"fail-safe"这个叫法不严谨?

JDK 官方文档中其实没有"fail-safe"这个术语。更准确的说法是"weakly consistent"(弱一致性)。因为 CopyOnWriteArrayList 的迭代器并不是"安全"——它只是不会抛 CME,但你看到的是快照,可能不是最新数据。而 ConcurrentHashMap 的迭代器甚至可能看到部分修改。所以在面试中,建议用"弱一致性迭代器"这个术语。

第 9 站

弱一致性迭代器:ConcurrentHashMap 的遍历哲学

高并发场景下 Map 的首选是 ConcurrentHashMap(CHM)。它的迭代器既不抛 CME,也不是严格快照,而是弱一致(weakly consistent)

  • 创建 Iterator 后,可能看到创建之后的修改,也可能看不到——不保证、不承诺
  • 保证不会抛出 ConcurrentModificationException
  • 保证不会看到"脏数据"——每个返回的元素在某个时间点确实存在过,不会出现被删元素的残留引用
对比代码 · 快照 vs 弱一致的行为差异
// ─── CopyOnWriteArrayList:严格快照,看不到任何后续修改 ───
CopyOnWriteArrayList<String> cowList = new CopyOnWriteArrayList<>(List.of("A", "B", "C"));
Iterator<String> cowIt = cowList.iterator();
cowList.add("D");                  // 触发复制,旧数组快照不变
while (cowIt.hasNext()) System.out.print(cowIt.next() + " ");
// 输出:A B C(永远看不到 D)

// ─── ConcurrentHashMap:弱一致,可能看到也可能看不到 ───
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("A", 1); map.put("B", 2); map.put("C", 3);
Iterator<String> chmIt = map.keySet().iterator();
map.put("D", 4);                  // 修改底层 table
while (chmIt.hasNext()) System.out.print(chmIt.next() + " ");
// 输出:A B C D 或 A B C(取决于遍历到那个桶时的时序)
CHM 为什么能做到弱一致?方法级答案

CHM 的迭代器是 Traverser 的子类,它不比较任何版本号(CHM 没有暴露给迭代器的 expectedModCount 语义),而是持有一个 table 引用直接遍历桶数组。JDK 8 扩容时旧桶会被替换成 ForwardingNode(hash 值为负数),Traverser.advance() 遇到它时会把遍历目标切换到 nextTable 继续——所以迭代器可能"半路进入新表",看到部分扩容后的数据。这就是"可能看到、也可能看不到"的底层来源。并发修改不会被检测,自然也永不 CME。

维度 ArrayList(fail-fast) CopyOnWriteArrayList(快照) ConcurrentHashMap(弱一致)
遍历中并发修改 抛 CME 不抛,看不到修改 不抛,可能看到部分修改
写开销 O(1) 摊还 O(n) 每次复制整数组 O(1) 平均(CAS + 细粒度同步)
读 / 遍历 O(1) 索引 O(1) 索引,无锁 遍历桶数组,弱一致
线程安全模型 不安全 读无锁 / 写加锁复制 CAS + synchronized 桶级同步
适用场景 单线程 读多写极少 高并发读写 KV
遍历安全选型一句话
  • 单线程遍历删除 → iterator.remove() / removeIf()
  • 读多写极少、遍历不能受影响 → CopyOnWriteArrayList(监听器列表、订阅者)
  • 高并发 KV 读改写 → ConcurrentHashMapcomputeIfAbsent/merge 配合)
  • 需要严格一致的遍历快照 → 先 new ArrayList<>(src) 拷贝一份再遍历
第 10 站

边界与盲区:fail-fast 到底有多"尽力而为"

把 fail-fast 用熟之后,真正拉开差距的是对盲区的认知。以下每个点都可能成为面试追问的落点。

盲区 1:hasNext() 不校验 modCount —— "删最后一个元素"的侥幸

EdgeCase.java · 什么时候"删了也不抛"
// 场景 A:foreach 里删"当前元素后面还有元素" → 必抛
List<String> list = new ArrayList<>(List.of("A", "B", "C", "D"));
for (String s : list) {
    if ("B".equals(s)) list.remove(s);  // 删 B 后还有 C、D → 下次 next() 抛 CME
}

// 场景 B:删除后 cursor 恰好等于新 size → 可能不抛!
List<String> list2 = new ArrayList<>(List.of("A", "B", "C"));
Iterator<String> it = list2.iterator();
it.next();  // A,cursor=1
it.next();  // B,cursor=2
list2.remove("C");  // 删的是游标之后的尾部元素 → size=2
// 下一轮 hasNext():cursor(2) != size(2) 为 false → 循环结束,无 CME
// 但这纯属侥幸:CME 的触发点只在 next()/remove() 内部,hasNext() 从不检查

精确结论:只要"结构性修改之后还会调用一次 next()",必抛 CME;若之后不再调用 next()(hasNext() 返回 false 或循环已结束),则检测不到。所以"删最后一个元素可能不抛"只是这类侥幸场景的特例,绝不能依赖它写业务代码。

盲区 2:偶数次修改相互抵消 & modCount 溢出

  • 偶数次抵消:并发下集合被改了偶数次,modCount 绕一圈又等于 expectedModCount——检测彻底失效。这就是"尽力而为"的数学本质。
  • int 溢出:modCount 是 int,理论上 2³¹ 次修改后变负数,2³² 次后绕回原值。实践中不可能,但理论上存在。
  • 不是线程安全的证明:即使不抛 CME,多线程下的 ArrayList 读也可能读到写了一半的数组(元素赋值非原子可见)。fail-fast 是"检测 bug 的哨兵",不是"并发安全保证"。

盲区 3:单线程同样触发 CME

SingleThread.java · 不是多线程的锅
List<String> list = new ArrayList<>(List.of("A", "B", "C"));
for (String s : list) {
    if ("B".equals(s)) {
        list.remove(s);   // CME!一个线程也会抛
    }
}
// 面试结论:fail-fast 不是线程安全机制,它是检测"错误用法"的手段
// 单线程的错误用法同样触发 CME——"并发"两个字只是名字,不代表只在并发下发生

盲区 4:SubList 与父 List 共享 modCount

SubList.java · 视图的隐藏陷阱
List<String> parent = new ArrayList<>(List.of("A", "B", "C", "D"));
List<String> sub = parent.subList(1, 3);  // [B, C] —— 视图,不是副本!

parent.add("E");       // 父集合 modCount +1
System.out.println(sub.get(0));
// → ConcurrentModificationException!
// SubList 每次操作前都检查"创建时的父 modCount"是否与当前一致

SubList 内部持有父 List 的引用和创建时的 expectedModCount,任何访问都会先做 checkForComodification。反过来,通过 sub 增删元素也会同步改到父集合,并同步自己的 expectedModCount。生产上最常见的坑是:把 subList 存起来用,期间父集合被别的代码改了。

盲区 5:Map 也有 fail-fast —— 遍历中 put 新键

MapPit.java · HashMap 遍历中的结构性修改
Map<String, Integer> map = new HashMap<>();
map.put("a", 1); map.put("b", 2);

for (Map.Entry<String, Integer> e : map.entrySet()) {
    map.put("c", 3);  // ❌ CME!新增 key 是结构性修改(modCount++)
}
// 注意:put 已有 key(覆盖 value)不是结构性修改,不会抛——又是"尽力而为"的体现

// ✅ 正确:
//  1) entrySet().removeIf(...)  只删不增
//  2) ConcurrentHashMap        遍历中随便 put
//  3) 先收集到新集合,遍历完再 putAll 合并
"HashMap 遍历中 put 一个已存在的 key,会抛 CME 吗?"

不会。覆盖已有 key 不改变 size,不是结构性修改,modCount 不变。只有新增 key(size 变化)才会让 modCount++。这个细节区分了"懂 fail-fast"和"背概念"两种人——fail-fast 盯的是结构,不是数据内容。
边界结论速记
  • CME 触发点只在 next()/remove() 内部;hasNext() 永不校验
  • 删除后不再调用 next() → 检测不到(侥幸,勿依赖)
  • 偶数次修改抵消 / modCount 溢出 → fail-fast 彻底失效
  • 单线程错误用法同样触发 CME;fail-fast ≠ 线程安全
  • SubList 是共享 modCount 的视图;HashMap 新增 key 才算结构性修改
第 11 站

生产最佳实践:从"不抛异常"到"写对代码"

生产环境里,CME 从来不是"并发集合一换就完事",而是要先想清楚数据的一致性语义。下面是后端场景里直接能抄的写法。

场景化代码:五种安全写法

Production.java · 后端场景对照
// ① 单线程批量条件删除:removeIf 一趟 O(n),首选
orders.removeIf(o -> o.getStatus() == Status.CANCELLED);

// ② 需要边删边做业务(记日志 / 发消息):显式 Iterator
Iterator<Order> it = orders.iterator();
while (it.hasNext()) {
    Order o = it.next();
    if (o.isExpired()) {
        auditLog.info("expire order {}", o.getId());
        it.remove();
    }
}

// ③ 读多写极少:监听器 / 订阅者列表 → CopyOnWriteArrayList
private final CopyOnWriteArrayList<OrderListener> listeners =
    new CopyOnWriteArrayList<>();
void notifyListeners(Order o) {
    for (OrderListener l : listeners) l.onOrder(o);  // 遍历中注册/注销都安全
}

// ④ 高并发 KV:ConcurrentHashMap(不要 HashMap + synchronized 自欺欺人)
ConcurrentHashMap<String, Session> sessions = new ConcurrentHashMap<>();
sessions.computeIfAbsent(token, Session::create);   // 原子读写

// ⑤ 必须遍历共享 ArrayList 快照:先拷贝,别在锁里遍历大集合
synchronized (orderLock) {
    for (Order o : new ArrayList<>(orders)) { // 快照遍历,锁内只做拷贝
        // 处理期间 orders 随便被改,互不影响
    }
}

Stream 的副作用陷阱(老生常谈但必考)

StreamPit.java · filter 里别动源集合
// ❌ 并行流中修改源集合——结果不可预测
list.parallelStream()
    .filter(s -> {
        list.add("X");   // 并发修改源 list!可能 CME、可能丢数据、可能"看起来正常"
        return true;
    })
    .count();

// ✅ Stream 必须无副作用:用中间流/新集合,最后一次性合并
List<String> result = list.stream()
    .filter(s -> s.length() > 1)
    .collect(Collectors.toList());

快速参考表(面试速查)

集合 迭代器类型 遍历中修改 安全删除方式
ArrayList / LinkedList fail-fast 抛 CME iterator.remove() / removeIf()
HashMap / HashSet / TreeMap fail-fast 抛 CME iterator.remove() / 视图 removeIf()
CopyOnWriteArrayList 快照(弱一致) 不抛,看不到修改 集合自身 remove(再复制一次)
ConcurrentHashMap 弱一致 不抛,可能看到修改 直接 put / remove / 视图 removeIf
ConcurrentLinkedQueue 弱一致 不抛 poll() / 迭代器 remove
fail-fast 检测公式:
CME 抛出条件 = (modCount != expectedModCount)
其中 expectedModCount 在 Iterator 创建时固定(iterator.remove() 后会同步更新),modCount 随每次结构性修改递增。
Code Review 检查清单
  • foreach 循环体内出现 list.add/remove/clear → 必改,大概率 CME
  • 共享集合跨线程遍历 → 换成并发集合,或遍历前拷贝快照
  • filter / map 里修改源集合 → 禁止(尤其 parallelStream)
  • subList 长期持有 → 确认期间父集合不再被结构性修改
  • 遍历删除一律 removeIf / iterator.remove(),不要在循环里碰运气
总结

这一篇你掌握了什么

核心知识点回顾

  • Iterator 模式:遍历逻辑从集合抽离,hasNext()/next()/remove() 统一接口,让算法与数据结构解耦;ListIterator 支持双向遍历
  • modCount:定义在 AbstractList 的结构性修改计数器,add/remove/clear 每次 +1;set() 不改结构不 +1;ArrayList 里"每次 add 都无条件 +1",扩容只是恰好发生在某次 add 内部
  • Itr 三件套cursor(下一个下标)、lastRet(上一个下标)、expectedModCount(创建时快照);next() 第一行执行 checkForComodification(),不一致即抛 CME
  • 增强 for 的本质:javac 翻译成 iterator()/hasNext()/next() 循环(javap 可见),迭代器引用被藏在局部变量表——所以 foreach 里既不能调 list.remove(),也调不到 it.remove()
  • Iterator.remove() 为何安全:删除后 expectedModCount = modCount 同步 + cursor 回退不跳元素;没 next() 就 remove 或连续两次 remove 抛 IllegalStateException
  • removeIf 不误报的真相:ArrayList 覆写版用 BitSet 记下标、双指针原地压缩、modCount 整个操作只 +1 一次;Collection 默认版用自家迭代器删,expectedModCount 自动同步
  • CopyOnWriteArrayList:volatile 数组 + ReentrantLock + 写时复制快照,永不 CME,但迭代器只读(remove 抛 UnsupportedOperationException),适合读多写极少
  • ConcurrentHashMap:弱一致迭代器,永不 CME,遍历中可能看到部分修改;底层 Traverser 遇 ForwardingNode 切 nextTable
  • fail-fast 的边界:hasNext() 不校验 modCount、删除后不再 next() 就检测不到、偶数次修改抵消、int 溢出——尽力而为,非线程安全

面试回答模板

Q:什么是 fail-fast 机制?

"fail-fast 是 Java 集合框架的一种错误检测机制。每个集合类内部维护一个 modCount 计数器,任何结构性修改(add、remove、clear)都会使其递增。Iterator 在创建时记录当前 modCount 为 expectedModCount,每次调用 next() 时通过 checkForComodification() 检查两者是否相等。如果不等,说明集合在遍历期间被修改过,立即抛出 ConcurrentModificationException。注意 fail-fast 是尽力而为的,不保证 100% 检测,JDK 文档明确说不能依赖它做正确性保证。"

Q:fail-fast 和 fail-safe(弱一致)有什么区别?

"fail-fast 集合(如 ArrayList、HashMap)在遍历中修改会抛 CME;弱一致集合(如 CopyOnWriteArrayList、ConcurrentHashMap)不会抛 CME。CopyOnWriteArrayList 的迭代器是快照语义,看到的是创建时的数组副本,且迭代器只读;ConcurrentHashMap 的迭代器是弱一致的,可能看到也可能看不到后续修改。代价上,COW 每次写都要复制整个数组,适合读多写少;ConcurrentHashMap 用 CAS + 桶级同步,适合高并发读写。"

Q:遍历中如何安全删除元素?

"有三种方式:第一,使用 Iterator.remove(),它在内部会同步 expectedModCount = modCount,且游标回退不跳元素;第二,使用 Java 8 的 removeIf(),ArrayList 的覆写实现用 BitSet 一趟批量删除,modCount 只加一次;第三,倒序索引遍历后直接 list.remove(i)。在 foreach 循环中直接调 list.remove() 是最常见的错误写法——单线程也会抛 CME。"

本篇核心要点
  • modCount 是结构性修改计数器,expectedModCount 是迭代器创建时的快照,两者不等 → CME
  • fail-fast 是尽力而为:检测 bug 的哨兵,不是线程安全机制,有明确盲区
  • 安全删除三法iterator.remove()removeIf()、倒序索引删除
  • 选型:单线程用 ArrayList + removeIf;读多写少用 COW;高并发 KV 用 ConcurrentHashMap
  • 写代码的底线:foreach 里不删、Stream 里不改源、subList 不长期持有

Comments · 评论