Lesson 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 是"一定能检测出并发修改"还是"尽力而为"?
  • CopyOnWriteArrayList 的 Iterator 为什么不抛异常?它的快照语义有什么代价?
  • 增强 for 循环里不能调用 remove(),但 Iterator.remove() 为什么就可以?

带着这些问题,我们从 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 遍历。这意味着你写的每一行 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 的并行增强版)。

第 3 站

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

fail-fast 机制的核心是一个计数器:modCount。它定义在 AbstractList(以及 AbstractMap 等抽象基类)中,记录集合被结构性修改的次数。

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 的操作都算。具体来说:

  • add() — 添加元素,size +1
  • remove() — 删除元素,size -1
  • clear() — 清空集合,size 归零
  • addAll() / removeAll() / retainAll() — 批量操作

set()(替换指定位置元素)不算结构性修改,因为它不改变 size。这就是为什么在遍历 ArrayList 时调用 list.set(i, newValue) 不会触发 CME。

来看 ArrayList 中 add()remove() 是怎么操作 modCount 的:

ArrayList.java · modCount 的递增时机
// add 方法 —— 每次成功添加都让 modCount +1
public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // 可能触发扩容,扩容也会 modCount++
    elementData[size++] = e;
    return true;
}

// remove 方法 —— 同样 modCount +1
public E remove(int index) {
    rangeCheck(index);
    modCount++;       // ← 注意这里
    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;
}

// ensureCapacityInternal → ensureExplicitCapacity
private void ensureExplicitCapacity(int minCapacity) {
    modCount++;       // ← 扩容也算结构性修改!
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}

Iterator 在创建时会"拍快照",记住当前的 modCount 值:

ArrayList.java · Iterator 内部类(关键字段)
private class Itr implements Iterator<E> {
    int cursor = 0;               // 下一个要返回的元素索引
    int lastRet = -1;             // 上一次 next()/previous() 返回的元素索引
    int expectedModCount = modCount; // 创建 Iterator 时拍快照

    final void checkForComodification() {
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }
}
为什么 modCount 不需要同步(synchronized)?

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

第 4 站

fail-fast 全链路:从创建到 BOOM

现在我们把整个 fail-fast 触发过程一步步走完。假设有一个 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();
// ↑ Iterator 创建,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() 方法的第一行就被调用,先于任何业务逻辑
  • 如果你恰好删除的是遍历中的最后一个元素,而且之后不再调用 next(),CME 可能不会被触发
fail-fast 是"一定能检测到并发修改"吗?

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

fail-safe:遍历并发修改不抛异常的代价

如果你的场景确实需要"一边遍历一边修改",fail-fast 集合就不够用了。JDK 提供了几种 fail-safe(更准确的说法是弱一致性)的并发集合:

CopyOnWriteArrayList —— 写时复制

CopyOnWriteArrayList.java · 核心思路
public class CopyOnWriteArrayList<E> {

    // 底层数组是 volatile 的,保证可见性
    private volatile transient 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);  // 原子替换引用
            return true;
        } finally {
            lock.unlock();
        }
    }

    // Iterator 拿到的永远是创建时的数组快照——不需要 modCount
    public Iterator<E> iterator() {
        return new COWIterator<>(getArray(), 0);
    }
}

核心原理:每次写操作都会复制整个底层数组,在新副本上修改,然后原子替换引用。Iterator 在创建时拿到的是当时数组的引用,后续写操作不影响这个旧数组——所以永远不会 CME。

ConcurrentHashMap —— 弱一致性迭代器

ConcurrentHashMap 的迭代器采用的是弱一致性(weakly consistent)策略:

  • 创建 Iterator 后,可能看到创建之后的修改,也可能看不到
  • 保证不会抛出 ConcurrentModificationException
  • 不会看到"脏数据"——每个返回的元素在某个时间点确实存在过
对比代码 · 两种迭代器的行为差异
// ─── CopyOnWriteArrayList:看到的是快照 ───
CopyOnWriteArrayList<String> cowList = new CopyOnWriteArrayList<>();
cowList.addAll(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  ← 可能看到也可能看不到(弱一致)

代价是什么?来看性能对比:

维度 ArrayList (fail-fast) CopyOnWriteArrayList (fail-safe) ConcurrentHashMap (弱一致)
写操作开销 O(1) 摊还 O(n) 每次写都复制整个数组 O(1) 平均,CAS + 锁分段
读/遍历开销 O(1) 索引访问 O(1) 索引访问,无锁 O(n) 遍历桶数组
内存占用 高(可能存在新旧两份数组) 中等
遍历中看到修改 抛 CME 看不到(快照) 不确定(弱一致)
适用场景 单线程 读多写少(如监听器列表) 高并发读写
为什么说"fail-safe"这个叫法不严谨?

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

第 6 站

避坑指南:六个高频翻车场景

坑 1:遍历中用 list.remove() → 用 iterator.remove()

Fix1.java · 错误 vs 正确
// ❌ 错误:用 list.remove(),modCount 与 expectedModCount 不同步
for (String s : list) {
    if ("B".equals(s)) {
        list.remove(s);  // CME!
    }
}

// ✅ 正确:用 iterator.remove(),内部会同步 expectedModCount
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    String s = it.next();
    if ("B".equals(s)) {
        it.remove();  // 安全!remove() 内部执行 expectedModCount = modCount
    }
}

坑 2:增强 for 循环里无法安全删除 → 用 removeIf()

Fix2.java · Java 8 最优解
// ❌ foreach 中无法调用 iterator.remove(),因为 Iterator 引用不可见
for (String s : list) {
    if (s.startsWith("tmp_")) {
        // 这里没有 it.remove() 可以调!
    }
}

// ✅ Java 8+ 最优雅的解法:removeIf(底层使用 Iterator.remove())
list.removeIf(s -> s.startsWith("tmp_"));

坑 3:SubList 和父 List 共享 modCount

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

parent.add("E");  // 修改了父 list 的 modCount

System.out.println(sub.get(0));
// → 抛出 ConcurrentModificationException!
// subList 操作时会检查 parent 的 modCount 是否与创建时一致

坑 4:HashMap 遍历中 put 新键

Fix4.java · Map 也有 fail-fast
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 是结构性修改
}

// ✅ 正确:用 entrySet().iterator().remove(),或 ConcurrentHashMap
Map<String, Integer> concMap = new ConcurrentHashMap<>(map);
concMap.forEach((k, v) -> {
    concMap.put("c", 3);  // ✅ 弱一致,不抛异常
});

坑 5:Stream 的 parallel 与并发修改

Fix5.java · 并行流的隐蔽陷阱
List<String> list = new ArrayList<>(List.of("A", "B", "C", "D"));

// ❌ 并行流中修改源集合——结果不可预测
list.parallelStream()
    .filter(s -> {
        list.add("X");  // 在 filter 中修改了源 list!
        return true;
    })
    .count();
// 可能抛 CME,也可能得到错误结果,也可能"看起来正常"

// ✅ 正确:Stream 应该是无副作用的,不要修改源集合
List<String> result = list.stream()
    .filter(s -> s.length() > 1)
    .collect(Collectors.toList());

坑 6:单线程也可能触发 CME

Fix6.java · 不是多线程的锅
// 单线程下最常见的 CME 场景:在 foreach 里删元素
List<String> list = new ArrayList<>(List.of("A", "B", "C"));

// 这不是多线程问题!这是用法错误!
for (String s : list) {
    if ("B".equals(s)) {
        list.remove(s);  // CME!单线程也会抛
    }
}

// 面试官问"fail-fast 和线程安全什么关系"时,核心答案:
// fail-fast 不是线程安全机制,它是检测"错误用法"的手段
// 单线程的错误用法同样会触发 CME
避坑核心原则
  • 遍历中要删元素:用 iterator.remove()removeIf(),绝不用集合自身的 remove
  • 遍历中要加元素:用 ConcurrentHashMap / CopyOnWriteArrayList,或收集到新集合再合并
  • SubList 是视图不是副本:父 list 修改后 subList 不可用
  • Stream 不要有副作用:filter/map 中不要修改源集合
第 7 站

总结:快速参考表

面试中遇到 Iterator 和 fail-fast 相关的问题,下面这张表可以作为快速参考:

集合类型 迭代器类型 遍历中修改 安全删除方式
ArrayList fail-fast 抛 CME iterator.remove() / removeIf()
LinkedList fail-fast 抛 CME iterator.remove() / removeIf()
HashMap fail-fast 抛 CME iterator.remove() / entrySet().removeIf()
HashSet fail-fast 抛 CME iterator.remove() / removeIf()
TreeMap / TreeSet fail-fast 抛 CME iterator.remove() / removeIf()
CopyOnWriteArrayList 快照(fail-safe) 不抛异常,看不到修改 直接 remove(),不影响进行中的遍历
ConcurrentHashMap 弱一致 不抛异常,可能看到修改 直接 remove() / forEach 中操作
ConcurrentLinkedQueue 弱一致 不抛异常 直接 poll() / remove()
fail-fast 检测公式:
CME 抛出条件 = (modCount != expectedModCount)
其中 expectedModCount 在 Iterator 创建时固定(iterator.remove() 后会同步更新),modCount 随每次结构性修改递增。

面试回答模板

Q:什么是 fail-fast 机制?

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

Q:fail-fast 和 fail-safe 有什么区别?

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

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

"有三种方式:第一,使用 Iterator.remove(),它在内部会同步 expectedModCount = modCount;第二,使用 Java 8 的 removeIf(),底层也是用 Iterator.remove();第三,倒序索引遍历后直接 list.remove(i)。在 foreach 循环中直接调 list.remove() 是最常见的错误写法。"

本篇核心要点
  • Iterator 是 GoF 迭代器模式的 Java 实现,提供统一的 hasNext()/next()/remove() 接口
  • modCount 是 AbstractList 中的结构性修改计数器,add/remove/clear 时递增
  • fail-fast 通过 checkForComodification() 比较 modCount 和 expectedModCount,不一致时抛 CME
  • fail-fast 是尽力而为,不能保证检测所有并发修改,不是线程安全机制
  • CopyOnWriteArrayList 用写时复制实现快照迭代器,读多写少场景首选
  • ConcurrentHashMap 用弱一致性迭代器,高并发读写场景首选
  • 安全删除三法:iterator.remove()、removeIf()、倒序索引遍历