集合遍历到底怎么才安全:fail-fast、弱一致、快照三种迭代器一次讲透
写业务常遇到遍历集合时改集合,运行时突然抛 ConcurrentModificationException。同一件事,HashMap 抛、ConcurrentHashMap 不抛、CopyOnWriteArrayList 也不抛——三种迭代器各守一套承诺。这篇用本机 JDK 26.0.1 的真实实验,把 fail-fast、弱一致、快照三种遍历语义的边界拆清楚,顺带讲透 for-each 删除的正确姿势。
下面六个实验,每段都是一份能直接复制运行的完整 Java 文件:存成对应文件名(如 Run1.java),javac -encoding UTF-8 Run1.java && java Run1 就能跑。源码带中文,Windows 上 javac 记得加 -encoding UTF-8;输出若在 GBK 控制台乱码,重定向到文件用 UTF-8 查看即可,不影响结果。想一次跑完全部六个,文末有拼好的整份 IterLab.java。
实验 1:HashMap 在 for-each 里删自己——抛 CME
最常见的错误写法:for (String k : map.keySet()) 循环体里直接 map.remove(k)。存成 Run1.java:
import java.util.*;
public class Run1 {
public static void main(String[] args) {
Map<String, Integer> m = new HashMap<>();
for (int i = 0; i < 6; i++) m.put("k" + i, i);
int seen = 0;
try {
for (String k : m.keySet()) { seen++; m.remove(k); }
} catch (ConcurrentModificationException e) {
System.out.println("读到第 " + seen + " 个后抛 ConcurrentModificationException,此刻 size=" + m.size());
}
}
}
跑出来的输出:
读到第 1 个后抛 ConcurrentModificationException,此刻 size=5
先记一个反直觉的点:key 是真的删掉了——size 从 6 变 5,HashMap 本身没坏、还能继续用,废掉的只是这次遍历。异常不是在 remove() 那一刻抛的,而是在下一次 next() 想取元素时才抛:第一次读到 k0、删掉它、size 变 5,这些都没事;第二次调 next() 才发现「结构被动过了」,立刻炸给你看。机制上叫「晚到一拍」,细节见下一节。
实验 2:换成迭代器自己的 remove——删光不抛
同一个 HashMap、同样删光六个 key,只是把删的动作交给迭代器。存成 Run2.java:
import java.util.*;
public class Run2 {
public static void main(String[] args) {
Map<String, Integer> m = new HashMap<>();
for (int i = 0; i < 6; i++) m.put("k" + i, i);
Iterator<String> it = m.keySet().iterator();
while (it.hasNext()) { it.next(); it.remove(); }
System.out.println("用 iterator.remove() 删光:没抛异常,size=" + m.size());
}
}
用 iterator.remove() 删光:没抛异常,size=0
同样删光,为什么不炸?因为 Iterator.remove() 删的时候会顺带把内部校验用的值同步好,让迭代器认为「结构没被动过」。正确的删法不止这一种,专门讲在后面的「想删」一节。
实验 3:ArrayList 遍历到一半 add——也抛 CME
删会炸,往里加也一样炸。存成 Run3.java:
import java.util.*;
public class Run3 {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 6; i++) list.add(i);
int seen = 0;
try {
for (int x : list) { seen++; if (x == 2) list.add(99); }
} catch (ConcurrentModificationException e) {
System.out.println("读到第 " + seen + " 个后抛 ConcurrentModificationException,此刻 size=" + list.size());
}
}
}
读到第 3 个后抛 ConcurrentModificationException,此刻 size=7
99 加进去了(size 变 7),下一次想取第 4 个元素时才炸。
把实验 1-3 并排看,差异点不在「删还是加」,而在两件事:改动是不是经由迭代器自己的 API 做的(实验 2 是,实验 1、3 不是);改完之后迭代器还会不会再取下一个元素(还会,所以炸)。支撑这套行为的机制在下面:
机制:modCount 计数器和「晚一步」的校验
异常名里的 Concurrent 极具误导性,它是说「检测到并发/并行的修改」,但单线程一样触发——上面实验 1、3 都是单线程。真正干活儿的是一对计数器:
- 集合自己维护一个
modCount(modification count,结构修改次数)。凡是结构性修改——add、remove、clear、HashMap 扩容、ArrayList 缩容移动元素——都会让它 +1;但只改已有元素的值(list.set(i, x)、map.put一个已存在的 key 覆盖旧值)不算结构性修改,不动 modCount。 - 迭代器创建那一刻记下
expectedModCount = modCount。之后每次next()取元素前先校验modCount == expectedModCount,不相等就说明在你遍历期间集合的结构被改过,抛ConcurrentModificationException。
增强 for-each 编译后底层就是一个迭代器加循环,所以实验 1 里 for (String k : map.keySet()) 展开就是 Iterator it = map.keySet().iterator(); while (it.hasNext()) { String k = it.next(); ... }。流程串起来:创建迭代器时 expectedModCount=0;你读到第一个 k、map.remove(k) 把 modCount 改成 1;下一轮 it.next() 一校验,1 ≠ 0,炸。实验 3 同理,add(99) 动了一次结构,下一次 next() 炸。
「结构性修改」和「改值」的分界是面试爱挖的点:只覆盖已有 key、不改变元素个数的 put 不会让正在进行的遍历炸;真正新增或删除元素、改变集合长度的操作才会。原因也直白——新增/删除会移动数组元素或链表节点,迭代器手里攥着的「下一个位置」可能已经失效或指错,再往下读会读到脏数据;只改某个位置的值,位置本身没动,迭代器继续读还是安全的。
再说清两个细节,免得面试被追问:
- fail-fast 是「尽力而为」的检测,不是铁律。 因为校验发生在
next(),如果结构性修改发生之后你恰好不再调用next()(比如删完立刻 break),就不会抛。所以它不能当并发安全机制用,只是一个「尽早暴露程序错误」的调试手段——名字里的 fail fast 就是这个意思:与其带着脏状态跑下去,不如让你第一时间发现。 - 哪些是 fail-fast 家族: ArrayList、LinkedList、HashMap(及其 keySet/entrySet/values 的迭代器)、HashSet、TreeMap 等。它们共性是「一次性」遍历内部数组或链表,最怕别人动结构。顺带记一句:老牌的 Vector、Hashtable 虽然方法是 synchronized 的,但它们的迭代器在现代 JDK 里同样是 modCount 校验、同样 fail-fast——「线程安全」和「遍历安全」是两回事。
想删,正确姿势有三种
实验 2 已经给了第一种:用迭代器自己的 remove()。JDK 里这个约定写在 Iterator 接口的 javadoc 上:遍历过程中,唯一的合法删法就是迭代器自己的 remove,删完之后的迭代行为仍然可靠。
但它有点啰嗦。日常其实三种写法由短到长:
// ① Java 8+:一句话,removeIf 内部就是用迭代器删的
map.keySet().removeIf(k -> 条件);
list.removeIf(x -> 条件);
// ② 遍历副本:要删的先记下来,遍历完统一删
for (String k : new ArrayList<>(map.keySet())) {
if (条件) map.remove(k);
}
// ③ 古老写法:显式迭代器 + it.remove()
Iterator<String> it = map.keySet().iterator();
while (it.hasNext()) { String k = it.next(); if (条件) it.remove(); }
removeIf 是现在的主流,JDK 8 给 Collection 加的默认方法,底层就是「迭代器遍历 + 迭代器 remove」,所以安全。面试说出「removeIf 内部就是迭代器删除」这句,比背「不能边遍历边删」高一个档次。
实验 4:ConcurrentHashMap 同线程边删边加——不抛,但顺序诡异
上面说的全是 fail-fast 的普通集合。真实的多线程并发容器走的是另一套语义。看实验 4——单线程,建好迭代器之后又删了两个 key 又加了八个,最后把迭代器遍历完。存成 Run4.java:
import java.util.*;
import java.util.concurrent.*;
public class Run4 {
public static void main(String[] args) {
ConcurrentHashMap<Integer, Integer> m = new ConcurrentHashMap<>();
for (int i = 0; i < 10; i++) m.put(i, i);
Iterator<Integer> it = m.keySet().iterator();
m.remove(0); m.remove(1); // 建迭代器后删除
for (int i = 100; i < 108; i++) m.put(i, i); // 再插入 8 个
List<Integer> seen = new ArrayList<>();
while (it.hasNext()) seen.add(it.next());
System.out.println("同线程建迭代器后边删边加:没抛 CME,遍历到 " + seen
+ ",map 现有 " + m.size() + " 个(顺序既不是插入序也不保证完整 = 弱一致)");
}
}
我本机一次输出(CHM 遍历顺序不保证,每次可能不一样,看特征即可):
同线程建迭代器后边删边加:没抛 CME,遍历到 [0, 2, 3, 4, 100, 5, 101, 6, 102, 7, 103, 8, 104, 9, 105, 106, 107],map 现有 16 个(顺序既不是插入序也不保证完整 = 弱一致)
两个观察点。第一,全程没抛 CME——ConcurrentHashMap 的迭代器根本没有 modCount 全局计数器可查。因为 CHM 把整表切成一堆桶、每个桶独立加锁,写操作只锁自己那个桶,维护一个「全局结构版本号」要么引入跨桶竞争、要么不准,CHM 干脆不维护,迭代器也就无从校验。第二,遍历到的东西很怪:16 个 key 全在,但顺序既不是插入序(100 夹在 4 和 5 中间),也看不出删加的时间关系——它看到的是「我走到哪个桶时,那个桶此刻的样子」。
实验 5:遍历 5 万个的同时,另一个线程写进 60 万——不抛、看见数在中间
再上一个更狠的并发测试:先放 5 万个 key,然后一个线程往里面猛写 60 万个新 key,主线程同时从头到尾把整张表遍历一遍。存成 Run5.java:
import java.util.*;
import java.util.concurrent.*;
public class Run5 {
public static void main(String[] args) {
ConcurrentHashMap<Integer, Integer> m = new ConcurrentHashMap<>();
for (int i = 0; i < 50_000; i++) m.put(i, i);
Thread w = new Thread(() -> { for (int i = 100_000; i < 700_000; i++) m.put(i, i); });
w.start();
int seen = 0;
try {
for (Integer k : m.keySet()) seen++;
System.out.println("遍历中并发写入:没抛 CME,本轮看见 " + seen + " 个");
} catch (ConcurrentModificationException e) {
System.out.println("并发遍历抛了 CME(不应发生)");
}
try { w.join(); } catch (InterruptedException e) {}
System.out.println("map 启动时 50000 个、writer 写完 join 后 650000 个,看见数在中间浮动 = 弱一致");
}
}
跑几轮,输出大致是这种形状:
遍历中并发写入:没抛 CME,本轮看见 81071 个
map 启动时 50000 个、writer 写完 join 后 650000 个,看见数在中间浮动 = 弱一致
本轮看到 81071 个,介于起始 50000 和最终 650000 之间。多跑几轮会浮动(我这台机器上见到的范围大致是 6.5 万到 8.1 万),但每次都大于 5 万、小于 65 万。这就是弱一致(weakly consistent)的含义:迭代器像一次穿过整张表的旅行,走到某个桶时看到的是那一刻这个桶的状态——writer 加进「我还没逛到的桶」里的 key,遍历完时我可能看到也可能漏掉,但绝不会因为别人在写而抛异常、也不会读到半截损坏的数据。它既不是遍历开始那刻的快照,也不是遍历结束那刻的实时状态,而是「沿途各桶的某个瞬间」拼起来的视图。
这里有个值得记住的工程权衡:想要「遍历时看到完全一致、又实时反映所有并发修改」的视图,容器就必须在遍历期间挡住写或者记一个全局版本——前者退化成锁、后者退化成竞争,都是 CHM 想避免的。弱一致就是并发读写下对「一致性」做的让步:牺牲「遍历过程中的一致性视图」,换「并发写不被遍历阻塞」。 ConcurrentHashMap、ConcurrentSkipListMap 的迭代器、以及它们的 size()、clear() 之类操作,都基于这个逻辑。
实验 6:CopyOnWrite 旧迭代器——只认创建时的快照
第三族是 CopyOnWriteArrayList / CopyOnWriteArraySet。存成 Run6.java:
import java.util.*;
import java.util.concurrent.*;
public class Run6 {
public static void main(String[] args) {
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
for (int i = 0; i < 3; i++) list.add(i);
Iterator<Integer> it = list.iterator();
list.add(99); // 迭代器创建后才改
List<Integer> seen = new ArrayList<>();
while (it.hasNext()) seen.add(it.next());
System.out.println("CopyOnWrite 旧迭代器看到 " + seen + "(看不到后加的 99)");
try {
it.remove();
} catch (UnsupportedOperationException e) {
System.out.println("旧迭代器 remove() 抛 UnsupportedOperationException");
}
}
}
CopyOnWrite 旧迭代器看到 [0, 1, 2](看不到后加的 99)
旧迭代器 remove() 抛 UnsupportedOperationException
先拿迭代器、再往 list 里 add 一个 99,回头用旧迭代器遍历,看到的还是创建那一刻的 [0, 1, 2],99 完全不可见。因为 CopyOnWrite 的迭代器持有的是创建瞬间那棵底层数组的引用,它遍历的是一份「凝固的快照」——之后任何 add/remove 都是复制出全新数组、把容器里的引用换成新数组,旧迭代器还攥着旧数组不放,自然看不到后加的 99,也正因为如此,它的遍历永远不可能抛 CME:你改你的新数组,我读我的旧数组,互不相干。
代价在哪?名字写得很清楚:每次写都要 copy-on-write 整份数组。一个 add 就是 O(n) 的数组复制加一次引用替换,写越频繁越贵;读则完全无锁、极快,因为读的数组是 volatile 引用、内容永远不变。所以它只适合「读极多、写极少」的场景,最典型的是观察者/监听器列表——注册和注销寥寥几次,广播时高频遍历。反过来,写频繁的列表用它就是灾难。
还有一个面试常挖的细节,上面代码已经演示:CopyOnWrite 的迭代器不支持 remove(),调用会抛 UnsupportedOperationException。道理顺理成章——快照是只读的,删它没有任何意义,JDK 索性在接口层面就让你删不了。
一张表收拢三种语义
| 集合(迭代器) | 遍历中被别人改结构 | 多线程安全吗 | 代价 / 备注 |
|---|---|---|---|
| fail-fast:ArrayList、HashMap、HashSet 等 | 下一次 next() 抛 CME | 不安全,改了立刻炸 | 零额外代价;删元素必须走迭代器 / removeIf |
| 弱一致:ConcurrentHashMap、ConcurrentSkipListMap | 不抛;可能看到部分最新写入、漏掉部分 | 安全 | 无全局锁;视图是「沿途各桶瞬间」拼的,不是一致快照 |
| 快照:CopyOnWriteArrayList / Set | 不抛;只能看到迭代器创建前的内容 | 安全 | 每次写复制整份数组 O(n);迭代器只读、不能 remove |
记忆锚点:fail-fast 靠「版本号对不上就炸」保护你;弱一致靠「没有全局版本号」换来并发;快照靠「复制一份旧数组」换来绝对安全。 三种都没法既要又要——想要实时一致视图又要并发写,只能在遍历端加外部锁,把并发退化掉。
面试速答
ConcurrentModificationException 是 fail-fast 的迭代器在 next() 时发现 modCount 和创建时的 expectedModCount 不一致抛的,表示遍历期间集合结构被改过(增删元素、扩容算,改已有元素值不算);单线程也触发,名字里的 Concurrent 是误导,它只是最早能发现「结构被动过」的手段,尽力而为、不是并发安全机制。正确删法是迭代器自己的 remove() 或 Java 8 的 removeIf()。并发容器换语义:ConcurrentHashMap 迭代器不校验 modCount,是弱一致——遍历时能看到部分新写入、可能漏部分,但不抛 CME、不被写阻塞;CopyOnWriteArrayList 迭代器持有创建时的数组快照,之后怎么写都看不到,代价是每次写 O(n) 复制且迭代器不支持 remove。面试追问「遍历时到底能不能改」,按三种迭代器分开答,就比一句笼统的「不能改」扎实得多。
本文关键词:Java集合、ConcurrentModificationException、fail-fast、ConcurrentHashMap、弱一致性
核心收获一句话:普通集合的遍历是「版本号对不上就炸」的 fail-fast,并发容器把一致性换成两种新语义——CHM 弱一致、CopyOnWrite 快照,选型前先想清楚你要的是实时一致、并发写,还是遍历绝对安全。下一步可以回自己的缓存/监听器代码里看用的是哪种集合:凡是「读多写少、遍历频繁」的列表,改成 CopyOnWriteArrayList 就能顺手消掉一批并发告警。
附:整份实验代码(一次跑全六个)
如果想把六个实验拼成一个文件一次跑完,用下面这份——内容就是上面六段代码的合体,只是每段包成一个 runN() 方法,main 里依次调用。存成 IterLab.java:
import java.util.*;
import java.util.concurrent.*;
/** 集合遍历三族:fail-fast / 弱一致 / 快照。JDK 26 实测。 */
public class IterLab {
public static void main(String[] args) {
run1(); run2(); run3(); run4(); run5(); run6();
}
static void run1() { // fail-fast:遍历中结构性删除
Map<String, Integer> m = new HashMap<>();
for (int i = 0; i < 6; i++) m.put("k" + i, i);
int seen = 0;
try {
for (String k : m.keySet()) { seen++; m.remove(k); }
} catch (ConcurrentModificationException e) {
System.out.println("读到第 " + seen + " 个后抛 ConcurrentModificationException,此刻 size=" + m.size());
}
}
static void run2() { // 正确删法:走迭代器自己的 remove
Map<String, Integer> m = new HashMap<>();
for (int i = 0; i < 6; i++) m.put("k" + i, i);
Iterator<String> it = m.keySet().iterator();
while (it.hasNext()) { it.next(); it.remove(); }
System.out.println("用 iterator.remove() 删光:没抛异常,size=" + m.size());
}
static void run3() { // fail-fast:遍历中结构性新增
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 6; i++) list.add(i);
int seen = 0;
try {
for (int x : list) { seen++; if (x == 2) list.add(99); }
} catch (ConcurrentModificationException e) {
System.out.println("读到第 " + seen + " 个后抛 ConcurrentModificationException,此刻 size=" + list.size());
}
}
static void run4() { // 单线程:遍历中改 CHM,不抛
ConcurrentHashMap<Integer, Integer> m = new ConcurrentHashMap<>();
for (int i = 0; i < 10; i++) m.put(i, i);
Iterator<Integer> it = m.keySet().iterator();
m.remove(0); m.remove(1); // 建迭代器后删除
for (int i = 100; i < 108; i++) m.put(i, i); // 再插入
List<Integer> seen = new ArrayList<>();
while (it.hasNext()) seen.add(it.next());
System.out.println("同线程建迭代器后边删边加:没抛 CME,遍历到 " + seen
+ ",map 现有 " + m.size() + " 个(顺序既不是插入序也不保证完整 = 弱一致)");
}
static void run5() { // 并发:遍历 5 万个的同时别线程写 60 万
ConcurrentHashMap<Integer, Integer> m = new ConcurrentHashMap<>();
for (int i = 0; i < 50_000; i++) m.put(i, i);
Thread w = new Thread(() -> { for (int i = 100_000; i < 700_000; i++) m.put(i, i); });
w.start();
int seen = 0;
try {
for (Integer k : m.keySet()) seen++;
System.out.println("遍历中并发写入:没抛 CME,本轮看见 " + seen + " 个");
} catch (ConcurrentModificationException e) {
System.out.println("并发遍历抛了 CME(不应发生)");
}
try { w.join(); } catch (InterruptedException e) {}
System.out.println("map 启动时 50000 个、writer 写完 join 后 650000 个,看见数在中间浮动 = 弱一致");
}
static void run6() { // CopyOnWrite 快照迭代器 + remove 不支持
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
for (int i = 0; i < 3; i++) list.add(i);
Iterator<Integer> it = list.iterator();
list.add(99); // 迭代器创建后才改
List<Integer> seen = new ArrayList<>();
while (it.hasNext()) seen.add(it.next());
System.out.println("CopyOnWrite 旧迭代器看到 " + seen + "(看不到后加的 99)");
try {
it.remove();
} catch (UnsupportedOperationException e) {
System.out.println("旧迭代器 remove() 抛 UnsupportedOperationException");
}
}
}
javac -encoding UTF-8 IterLab.java && java IterLab
(源码带中文,Windows 上 javac 记得加 -encoding UTF-8;输出若在 GBK 控制台乱码,重定向到文件用 UTF-8 查看即可,不影响结果。)