Lesson 09 · 集合框架源码
Iterator 与 fail-fast:集合遍历的安全边界
凌晨 2 点的 ConcurrentModificationException
凌晨 2:17,生产环境的告警钉钉把值班同学从床上炸醒。打开日志一看,满屏都是同一个异常:
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)。
很多同学的回答停留在"modCount 不一致就抛异常"就说不下去了。但高级工程师需要回答的是:
modCount在哪个层级定义?哪些操作会触发它递增?- fail-fast 是"一定能检测出并发修改"还是"尽力而为"?
CopyOnWriteArrayList的 Iterator 为什么不抛异常?它的快照语义有什么代价?- 增强 for 循环里不能调用
remove(),但Iterator.remove()为什么就可以?
带着这些问题,我们从 Iterator 的设计模式源头开始,逐层拆解。
Iterator 设计模式:统一遍历的抽象之美
Iterator 模式是 GoF《设计模式》中的经典行为模式,核心思想是:将集合的遍历逻辑从集合本身中抽离出来,封装到一个独立的迭代器对象中。这样,集合类只需关心数据存储,遍历职责完全交给 Iterator。
Java 的 java.util.Iterator 接口是这个模式的标准实现,定义极其精简:
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());
}
}
有了这个接口,不管底层是数组、链表还是红黑树,调用者都可以用统一的方式遍历:
// 以下两段代码结构完全一致,尽管底层数据结构截然不同
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,调用者需要了解每种集合的内部结构:ArrayList 用索引遍历、LinkedList 用节点跳转、HashMap 要遍历桶数组 + 链表。Iterator 把这些差异封装了,让算法可以独立于数据结构编写。这也是为什么 Java 8 的 Stream API 可以在任意 Collection 上工作——它底层依赖的就是 Spliterator(Iterator 的并行增强版)。
modCount:集合的"修改版本号"
fail-fast 机制的核心是一个计数器:modCount。它定义在 AbstractList(以及 AbstractMap 等抽象基类)中,记录集合被结构性修改的次数。
public abstract class AbstractList<E> extends AbstractCollection<E>
implements List<E> {
// 结构性修改的计数:add, remove, clear 等操作每次 +1
// 注意:它是 protected 的,子类可以直接访问
protected transient int modCount = 0;
}
什么叫"结构性修改"?任何改变了集合 size 的操作都算。具体来说:
add()— 添加元素,size +1remove()— 删除元素,size -1clear()— 清空集合,size 归零addAll()/removeAll()/retainAll()— 批量操作
而 set()(替换指定位置元素)不算结构性修改,因为它不改变 size。这就是为什么在遍历 ArrayList 时调用 list.set(i, newValue) 不会触发 CME。
来看 ArrayList 中 add() 和 remove() 是怎么操作 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 值:
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();
}
}
因为 fail-fast 本身就不是为线程安全设计的。modCount 是一个"尽力而为"的检测机制,它主要防范的是单线程下的错误用法(比如在 foreach 里调 list.remove())。多线程场景下,即使 modCount 碰巧相等,也不代表线程安全——fail-fast 的 Javadoc 里明确写了"不保证检测到并发修改"。
fail-fast 全链路:从创建到 BOOM
现在我们把整个 fail-fast 触发过程一步步走完。假设有一个 ArrayList,已经有 3 个元素,经历过 3 次 add,所以 modCount = 3:
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!
}
这里有几个细节需要注意:
- 异常不是在
list.remove()那一刻抛出的——而是在下一次调用it.next()时才检测到 checkForComodification()在next()方法的第一行就被调用,先于任何业务逻辑- 如果你恰好删除的是遍历中的最后一个元素,而且之后不再调用
next(),CME 可能不会被触发
不是。JDK 文档明确说了:fail-fast 是一种尽力而为(best-effort)的机制,不能保证 100% 检测。例如 modCount 是 int 类型,极端情况下可能溢出回到原来的值;或者在 Iterator 的两次操作之间集合被修改了偶数次,modCount 碰巧又等于 expectedModCount。所以 fail-fast 只用于检测bug,不能依赖它来实现业务逻辑。
fail-safe:遍历并发修改不抛异常的代价
如果你的场景确实需要"一边遍历一边修改",fail-fast 集合就不够用了。JDK 提供了几种 fail-safe(更准确的说法是弱一致性)的并发集合:
CopyOnWriteArrayList —— 写时复制
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 | 看不到(快照) | 不确定(弱一致) |
| 适用场景 | 单线程 | 读多写少(如监听器列表) | 高并发读写 |
JDK 官方文档中其实没有"fail-safe"这个术语。更准确的说法是"weakly consistent"(弱一致性)。因为 CopyOnWriteArrayList 的迭代器并不是"安全"——它只是不会抛 CME,但你看到的是快照,可能不是最新数据。而 ConcurrentHashMap 的迭代器甚至可能看到部分修改。所以在面试中,建议用"弱一致性迭代器"这个术语。
避坑指南:六个高频翻车场景
坑 1:遍历中用 list.remove() → 用 iterator.remove()
// ❌ 错误:用 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()
// ❌ 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
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 新键
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 与并发修改
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
// 单线程下最常见的 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 中不要修改源集合
总结:快速参考表
面试中遇到 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() |
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()、倒序索引遍历