JAVA · Vol.I · DAY 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 是"一定能检测出并发修改"还是"尽力而为"?哪些场景下它检测不到?
Iterator.remove()为什么安全?removeIf()内部做了什么,为什么它自己遍历自己删却不抛 CME?- 增强 for 循环的本质是什么?
CopyOnWriteArrayList的快照语义又有什么代价?
带着这些问题,我们从 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 遍历(下一站我们会用 javap 反编译验证)。这意味着你写的每一行 for (T item : collection),背后都在使用 Iterator 模式。
如果没有 Iterator,调用者需要了解每种集合的内部结构:ArrayList 用索引遍历、LinkedList 用节点跳转、HashMap 要遍历桶数组 + 链表。Iterator 把这些差异封装了,让算法可以独立于数据结构编写。这也是为什么 Java 8 的 Stream API 可以在任意 Collection 上工作——它底层依赖的就是 Spliterator(Iterator 的并行增强版)。顺带一提:ListIterator 是 Iterator 的 List 专用扩展,额外提供 hasPrevious()/previous()/add()/set(),支持双向遍历与在迭代中插入元素。
modCount:集合的"修改版本号"
fail-fast 机制的核心是一个计数器:modCount。它定义在 AbstractList(HashMap 则在自身类中重新声明了一个同名字段)中,记录集合被结构性修改的次数。
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,结论不变):
// 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;
}
网上很多资料说"扩容算结构性修改,所以 modCount++",这个说法不够精确。modCount++ 写在 ensureExplicitCapacity 的第一行,是无条件执行的——即使数组容量足够、根本没触发 grow(),这次 add 依然 +1。扩容只是恰好发生在某次 add 的内部。理解了这一点,你就知道为什么"扩容 1.5 倍 / 2 倍"这类阈值与 fail-fast 的检测粒度无关:fail-fast 关心的是"结构变了多少次",而不是"内存搬了多少"。
因为 fail-fast 本身就不是为线程安全设计的。modCount 是一个"尽力而为"的检测机制,它主要防范的是单线程下的错误用法(比如在 foreach 里调 list.remove())。多线程场景下,即使 modCount 碰巧相等,也不代表线程安全——fail-fast 的 Javadoc 里明确写了"不保证检测到并发修改"。
Itr 源码拆解:fail-fast 全链路
ArrayList.iterator() 返回的是一个名为 Itr 的内部类实例(日志里那个 ArrayList$Itr 就是它)。它只有三个字段、三个方法,却承载了全部检测逻辑:
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:
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!
}
从源码可以提炼出三个"细节级"结论,面试时说出来就是加分项:
- 异常不是在
list.remove()那一刻抛出的——而是在下一次调用it.next()时才检测到;checkForComodification()位于next()的第一行,先于任何业务逻辑。 hasNext()只是cursor != size,从不校验 modCount——这是后面"删最后一个元素可能不抛"这类盲区的根源。next()里还有一道"双保险":i >= elementData.length也会抛 CME。它专门捕获"集合被删得比 size 还快"的极端竞态。
不是。JDK 文档明确说了:fail-fast 是一种尽力而为(best-effort)的机制,不能保证 100% 检测。例如 modCount 是 int 类型,极端情况下可能溢出回到原来的值;或者在 Iterator 的两次操作之间集合被修改了偶数次,modCount 碰巧又等于 expectedModCount。所以 fail-fast 只用于检测bug,不能依赖它来实现业务逻辑。
增强 for 的本质:编译器替你写的 Iterator 代码
增强 for 是语法糖。它的"真身"是什么?用 javap -c 反编译一个最简单的类就能看到真相:
// 源: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 + get(i) 略快——少一次迭代器对象分配,且
get(i) 直接下标访问;增强 for 多一层 hasNext/next 调用。但这点差距在 O(1) 级别的访问开销面前微乎其微。真正的性能陷阱是:LinkedList 用普通 for + get(i),每次 get 都要从头遍历,整体 O(n²),这才会被打爆。结论:优先可读性,用增强 for;要遍历中删除,显式 Iterator 或 removeIf。标准答法分三层:① 不能调 list.remove()——会 CME(单线程也一样);② 也调不了 it.remove()——编译器生成的迭代器引用被藏在局部变量表里,源码拿不到;③ 正确姿势是改成显式 Iterator 循环调 it.remove(),或直接用 Java 8 的 removeIf()。能一口气答出这三层,说明你真的看过字节码。
Iterator.remove():为什么它是唯一的安全删除通道
同样的 remove,走 list.remove() 抛 CME,走 it.remove() 就没事。差异全在 Itr.remove() 的源码里——它多做了三件事:
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 的完整内部账本
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 都被遍历到——没有漏元素,也没有抛异常
不会漏。删除后
cursor = lastRet 回退到被删位置,而删除动作让后面的元素整体左移一位填补空位,所以下一次 next() 返回的恰好就是"原被删元素后面的那个"。cursor 回退 + 数组左移,两者抵消,遍历语义完美连续。边界条件:IllegalStateException 的两道闸
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 里碰运气:即使"这次没抛",也是侥幸,下一个元素就可能炸
JDK 8 removeIf:一趟批量删除,如何避免"自误报"
Java 8 给 Collection 加了一个 default 方法 removeIf(Predicate),一行搞定"遍历 + 条件删除"。它最反直觉的地方是:它自己明明也在"遍历中删除",却不抛 CME。秘密要看两层实现。
第一层:Collection 的默认实现(LinkedList / HashSet 走这里)
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。这就是为什么 LinkedList、HashSet 调 removeIf 也安全。
第二层:ArrayList 的覆写——BitSet 批量删除
ArrayList 嫌弃逐条删太慢(每次删除都要 System.arraycopy 搬移后续元素,删 m 个最坏 O(n·m)),于是在 JDK 8 覆写了 removeIf,改成"一趟扫描 + BitSet 标记 + 原地压缩":
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;
}
三个原因叠在一起:① 它根本不创建 Iterator,而是直接按下标扫描底层数组,不存在"别人的 expectedModCount";② 删除不是逐条进行的,而是先记下所有要删的下标,最后一次性原地压缩,modCount 只 +1 一次——整个操作对版本号来说只算一次结构性修改;③ 扫描期间若真被并发修改,循环条件
modCount == expectedModCount 会提前退出并抛 CME,绝不会把列表改坏一半再继续。removeIf 不是"免疫",只是"自己不误报"
Iterator<String> it = list.iterator();
list.removeIf(s -> s.startsWith("tmp")); // ✅ 自身安全,但 modCount +1 了
it.next(); // ❌ CME!你手上这个迭代器的 expectedModCount 已过期
// 结论:removeIf 只保证"自己这一趟"不出事,
// 任何其他正在进行的遍历,都会被它的一次 modCount++ 打断
① 性能:n 个元素删 m 个,逐条 iterator.remove() 最坏 O(n·m)(每次删除都搬移后续元素);removeIf 一趟 O(n)。100 万元素删 50 万,量级天壤之别。② 异常安全性:ArrayList 的覆写版本里,如果谓词在扫描期抛异常,列表尚未开始压缩,处于"原封不动"状态;而 Collection 默认实现是边遍历边删,谓词抛异常时可能已删了一部分。③ HashMap 的 keySet()/entrySet()/values() 在 JDK 8 也覆写了 removeIf,直接扫桶数组删除,同样安全高效。
- 单线程批量条件删除,无脑用
removeIf:一行代码、O(n)、不误报 CME - Map 过滤:用
map.entrySet().removeIf(...)或map.values().removeIf(...),不要手写遍历再 put - 需要边删边做业务逻辑(记日志、发消息):才退回显式
Iterator循环
fail-safe:CopyOnWriteArrayList 的写时复制快照
如果业务确实需要"一边遍历一边被别人改"且不抛异常,就要换并发集合了。JDK 里最典型的 fail-safe(更准确的说法是弱一致性 / 快照一致性)集合是 CopyOnWriteArrayList(COW)。
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。
快照的代价:COWIterator 连 remove 都不支持
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 的迭代器是只读的,想要"遍历中删"请改用
list.removeIf()(它会基于当前快照创建新数组)。适用场景与代价
| 维度 | ArrayList | CopyOnWriteArrayList |
|---|---|---|
| 写操作 | O(1) 摊还 | O(n):每次写都复制整数组 |
| 读 / 遍历 | O(1),无锁 | O(1),无锁,且永不 CME |
| 遍历中别人修改 | 抛 CME | 不抛;但也看不到新数据(快照) |
| 内存 | 低 | 高:写瞬间新旧两份数组并存 |
| 典型场景 | 单线程 / 读改写均衡 | 读多写极少:监听器注册表、订阅者列表、黑白名单 |
JDK 官方文档中其实没有"fail-safe"这个术语。更准确的说法是"weakly consistent"(弱一致性)。因为 CopyOnWriteArrayList 的迭代器并不是"安全"——它只是不会抛 CME,但你看到的是快照,可能不是最新数据。而 ConcurrentHashMap 的迭代器甚至可能看到部分修改。所以在面试中,建议用"弱一致性迭代器"这个术语。
弱一致性迭代器:ConcurrentHashMap 的遍历哲学
高并发场景下 Map 的首选是 ConcurrentHashMap(CHM)。它的迭代器既不抛 CME,也不是严格快照,而是弱一致(weakly consistent):
- 创建 Iterator 后,可能看到创建之后的修改,也可能看不到——不保证、不承诺
- 保证不会抛出 ConcurrentModificationException
- 保证不会看到"脏数据"——每个返回的元素在某个时间点确实存在过,不会出现被删元素的残留引用
// ─── 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 的迭代器是 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 读改写 →
ConcurrentHashMap(computeIfAbsent/merge配合) - 需要严格一致的遍历快照 → 先
new ArrayList<>(src)拷贝一份再遍历
边界与盲区:fail-fast 到底有多"尽力而为"
把 fail-fast 用熟之后,真正拉开差距的是对盲区的认知。以下每个点都可能成为面试追问的落点。
盲区 1:hasNext() 不校验 modCount —— "删最后一个元素"的侥幸
// 场景 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
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
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 新键
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 合并
不会。覆盖已有 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 才算结构性修改
生产最佳实践:从"不抛异常"到"写对代码"
生产环境里,CME 从来不是"并发集合一换就完事",而是要先想清楚数据的一致性语义。下面是后端场景里直接能抄的写法。
场景化代码:五种安全写法
// ① 单线程批量条件删除: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 的副作用陷阱(老生常谈但必考)
// ❌ 并行流中修改源集合——结果不可预测
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 |
CME 抛出条件 = (modCount != expectedModCount)其中
expectedModCount 在 Iterator 创建时固定(iterator.remove() 后会同步更新),modCount 随每次结构性修改递增。
- 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 · 评论