← 返回博客
2026-09-03 10:00:00

集合遍历到底怎么才安全:fail-fast、弱一致、快照三种迭代器一次讲透

集合遍历到底怎么才安全: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 都是单线程。真正干活儿的是一对计数器:

增强 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 不会让正在进行的遍历炸;真正新增或删除元素、改变集合长度的操作才会。原因也直白——新增/删除会移动数组元素或链表节点,迭代器手里攥着的「下一个位置」可能已经失效或指错,再往下读会读到脏数据;只改某个位置的值,位置本身没动,迭代器继续读还是安全的。

再说清两个细节,免得面试被追问:

想删,正确姿势有三种

实验 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 查看即可,不影响结果。)