并发修改异常与遍历中的安全删除 | JavaSE

并发修改异常与遍历中的安全删除

一、学习目标

完成本章后,你应该能够:

  • 能够解释什么是遍历过程中的结构性修改。
  • 能够理解 ConcurrentModificationException 的含义,并知道它不一定意味着发生了多线程并发。
  • 能够解释为什么增强 for 遍历期间直接调用集合自身的 addremove 等操作可能出问题。
  • 能够使用 Iterator.remove() 安全删除当前迭代元素。
  • 能够使用正序索引 for + i-- 和倒序索引 for 解决 List 删除后的索引移动问题。
  • 能够理解 removeIf 在“按条件批量删除”场景中的价值。
  • 能够区分“Iterator 安全删除”“索引删除”“增强 for/forEach 中直接修改集合”之间的差异。

二、核心知识

2.1 什么是遍历中的修改

考虑集合:

List<String> names =
        new ArrayList<>();

names.add("张三");
names.add("李四");
names.add("张三");
names.add("王五");

现在要求:

删除所有 "张三"

很多初学者第一反应:

for (String name : names) {

    if (name.equals("张三")) {
        names.remove(name);
    }
}

看起来逻辑非常自然:

遍历元素
↓
发现张三
↓
从集合删除张三

但这里隐藏着一个重要问题:

遍历过程本身正在依赖集合结构,而你又在遍历过程中直接改变集合结构。


2.2 什么叫结构性修改

以 ArrayList 为例,下面操作通常属于典型结构性修改:

add(...)
remove(...)
clear()

它们可能改变:

集合中元素数量

元素位置

集合内部结构

例如:

原集合:

0 张三
1 李四
2 王五

删除索引 0:

0 李四
1 王五

原来的:

索引 1
索引 2

发生了移动。

如果遍历器还按照原先状态继续遍历,就可能:

跳过数据
访问错误位置
或者检测到非法修改

2.3 ConcurrentModificationException 是什么

Java 中有一个异常:

ConcurrentModificationException

通常缩写:

CME

它经常出现在:

使用 fail-fast 迭代器遍历集合时,集合在迭代器不知情的情况下发生了结构性修改。

例如:

List<String> names =
        new ArrayList<>();

names.add("张三");
names.add("李四");
names.add("张三");

for (String name : names) {

    if (name.equals("张三")) {
        names.remove(name);
    }
}

在典型 ArrayList 场景下可能抛出:

ConcurrentModificationException

2.4 “Concurrent” 不等于“一定有多线程”

这是必须纠正的一个常见误区。

很多人看到:

ConcurrentModificationException

会自动理解为:

“一定是两个线程同时修改集合。”

不是。

例如下面所有代码都发生在:

同一个 main 线程

中:

List<String> names =
        new ArrayList<>();

names.add("张三");
names.add("李四");
names.add("张三");

for (String name : names) {

    if (name.equals("张三")) {
        names.remove(name);
    }
}

依然可能产生:

ConcurrentModificationException

这里的核心含义更接近:

迭代过程与集合结构修改发生了不兼容的交叉。

因此:

ConcurrentModificationException
≠ 必然是多线程问题

2.5 为什么会有 fail-fast

许多 Java 集合的普通迭代器属于:

fail-fast iterator。

可以简单理解为:

发现遍历过程中集合发生了不符合规则的结构性变化
↓
尽快失败
↓
抛出异常

而不是:

明知道遍历状态可能已经失效
还继续返回不可预测的数据

因此 fail-fast 是:

一种帮助程序员尽早发现错误修改方式的机制。


2.6 fail-fast 不是程序正确性的保障

必须特别注意:

不能写出依赖 ConcurrentModificationException 是否出现来判断程序正确与否的代码。

错误思想:

try {

    // 边遍历边修改

} catch (ConcurrentModificationException e) {

    // 没抛异常就说明代码正确
}

这是错误的。

fail-fast 检测通常是:

best-effort

即:

尽力检测,而不是绝对保证所有非法修改都必定抛出异常。

因此真正正确的做法是:

从一开始就选择合法的集合修改方式。


三、使用方法

3.1 错误方案:增强 for 中直接 remove 集合

例如:

List<String> names =
        new ArrayList<>();

names.add("张三");
names.add("李四");
names.add("张三");

for (String name : names) {

    if (name.equals("张三")) {
        names.remove(name);
    }
}

问题在于:

增强 for
↓
依赖迭代机制

同时

names.remove(...)
↓
直接修改底层集合

两个操作互相干扰。

因此:

增强 for 更适合遍历读取,而不是直接结构性修改原集合。


3.2 方案一:Iterator.remove()

如果使用 Iterator 遍历:

Iterator<String> iterator =
        names.iterator();

那么需要删除:

当前刚刚由 Iterator 返回的元素

时,应该优先使用:

iterator.remove();

例如:

List<String> names =
        new ArrayList<>();

names.add("张三");
names.add("李四");
names.add("张三");
names.add("王五");

Iterator<String> iterator =
        names.iterator();

while (iterator.hasNext()) {

    String name =
            iterator.next();

    if (name.equals("张三")) {

        iterator.remove();
    }
}

System.out.println(names);

结果:

[李四, 王五]

3.3 为什么 Iterator.remove 可以安全删除

关键区别:

错误:

names.remove(name);

正确的 Iterator 删除方式:

iterator.remove();

后者告诉:

负责当前遍历状态的 Iterator

自己完成删除。

因此 Iterator 能够同步维护自己的迭代状态。

可以理解为:

Iterator:

我刚刚返回了这个元素
↓
现在由我删除它
↓
我知道遍历状态该怎么继续

而不是:

Iterator 正在遍历

另一个调用突然:
names.remove(...)

Iterator:
???

3.4 Iterator.remove 删除的是哪个元素

观察:

String name =
        iterator.next();

if (name.equals("张三")) {
    iterator.remove();
}

remove() 删除的是:

最近一次 next() 返回的元素。

因此标准模式:

while (iterator.hasNext()) {

    String element =
            iterator.next();

    if (满足删除条件) {
        iterator.remove();
    }
}

3.5 Iterator.remove 不能随便调用

错误:

Iterator<String> iterator =
        names.iterator();

iterator.remove();

因为此时:

还没有调用 next()

迭代器不知道:

你到底想删除哪个“刚刚返回的元素”。

因此可能抛出:

IllegalStateException

正确:

iterator.next();

iterator.remove();

3.6 一次 next() 后不能连续 remove 两次

错误:

iterator.next();

iterator.remove();

iterator.remove();

同一个:

next()

之后:

remove()

最多正常对应一次。

第二次可能抛出:

IllegalStateException

所以记忆:

next()
↓
得到当前元素
↓
最多 remove() 一次

3.7 方案二:正序索引遍历 + i--

对于:

List

这种支持索引访问的集合,可以使用普通 for。

例如:

List<String> names =
        new ArrayList<>();

names.add("张三");
names.add("张三");
names.add("李四");

错误写法:

for (int i = 0;
     i < names.size();
     i++) {

    if (names.get(i).equals("张三")) {
        names.remove(i);
    }
}

这段代码不一定抛 CME。

但是可能出现:

元素漏删。


3.8 为什么正序删除会漏元素

原始数据:

索引 0:张三
索引 1:张三
索引 2:李四

第一次:

i = 0

删除索引 0:

索引 0:张三
索引 1:李四

注意:

原来的第二个:

张三

向前移动到了:

索引 0

但是 for 循环马上执行:

i++

变成:

i = 1

于是程序直接检查:

李四

刚刚移动到索引 0 的第二个 "张三"

被跳过去了。


3.9 使用 i-- 修复正序删除

解决:

for (int i = 0;
     i < names.size();
     i++) {

    if (names.get(i).equals("张三")) {

        names.remove(i);

        i--;
    }
}

为什么:

i--;

因为删除后:

后面的元素整体前移

我们需要让下一轮继续检查:

当前索引位置

而不是直接跳到下一个索引。


3.10 正序删除完整示例

import java.util.ArrayList;
import java.util.List;

public class ForwardRemoveDemo {

    public static void main(String[] args) {

        List<String> names =
                new ArrayList<>();

        names.add("Java");
        names.add("宁夏枸杞");
        names.add("黑枸杞");
        names.add("人字拖");
        names.add("特级枸杞");
        names.add("枸杞子");
        names.add("西洋参");

        for (int i = 0;
             i < names.size();
             i++) {

            String name =
                    names.get(i);

            if (name.contains("枸杞")) {

                names.remove(i);

                i--;
            }
        }

        System.out.println(names);
    }
}

3.11 方案三:倒序索引删除

还有一种非常经典的 List 删除方式:

从后向前删除。

例如:

for (int i = names.size() - 1;
     i >= 0;
     i--) {

    if (names.get(i)
             .equals("张三")) {

        names.remove(i);
    }
}

3.12 为什么倒序遍历不容易漏元素

原数据:

0 A
1 B
2 C
3 D

假设从:

i = 3

开始。

删除:

索引 3

前面的:

0
1
2

位置不会因为删除后面元素而改变。

继续:

i = 2

即可。

因此:

从后往前删,可以规避“后面的元素前移后被跳过”的问题。


3.13 Iterator 与索引删除怎么选

如果处理的是通用:

Collection

并且需要遍历过程中删除:

Iterator.remove()

更加通用。

如果已经明确:

List

并且需要:

按索引处理

可以考虑:

倒序索引遍历

或者

正序 + i--

3.14 forEach / Lambda 中直接修改原集合

例如:

names.forEach(name -> {

    if (name.equals("张三")) {

        names.remove(name);
    }
});

这同样属于危险写法。

因为:

forEach
↓
正在遍历

Lambda
↓
直接结构性修改同一个底层集合

官方对 Iterable.forEach 的规则是:

如果遍历动作修改底层元素来源,其行为并没有统一保证,除非具体实现明确规定并发修改策略。

对于普通 ArrayList 等集合,常见结果就是:

ConcurrentModificationException

因此:

forEach 更适合执行遍历操作,而不是直接结构性修改正在遍历的原集合。


3.15 JDK 21 工程补充:removeIf

如果需求只是:

删除所有满足某个条件的元素。

其实 Collection 已经提供:

removeIf(...)

例如:

List<String> names =
        new ArrayList<>();

names.add("张三");
names.add("李四");
names.add("张三");
names.add("王五");

names.removeIf(
        name -> name.equals("张三")
);

System.out.println(names);

结果:

[李四, 王五]

甚至:

names.removeIf(
        "张三"::equals
);

3.16 removeIf 为什么适合条件删除

传统 Iterator:

Iterator<String> iterator =
        names.iterator();

while (iterator.hasNext()) {

    String name =
            iterator.next();

    if (name.equals("张三")) {

        iterator.remove();
    }
}

removeIf

names.removeIf(
        name -> name.equals("张三")
);

表达的业务意图非常直接:

删除所有满足条件的元素

因此如果不需要复杂的遍历逻辑:

removeIf 通常是非常清晰的选择。

但 Lambda 与函数式接口将在后面的专章系统学习。


四、原理与进阶

4.1 fail-fast 的核心目的

fail-fast 并不是为了:

实现线程安全

而是为了:

尽早发现迭代过程中发生的不兼容修改。

因此:

fail-fast
≠ thread-safe

这是两个完全不同的问题。


4.2 ConcurrentModificationException 也不是线程锁

抛出:

ConcurrentModificationException

并不表示:

Java 自动给集合加锁了

也不表示:

集合变成线程安全了

它只是:

发现了一种可能导致迭代过程失效的修改状态。

真正的线程安全属于:

多线程与并发

章节的内容。


4.3 什么是非结构性修改

以 ArrayList 为例:

set(index, value)

只是:

替换某个位置上的元素。

一般不会改变:

元素数量
集合整体结构

因此 ArrayList 官方将类似:

只替换元素值

与:

add/remove

这种结构性修改区分开来。

不过:

是否允许某种修改以及具体迭代语义,最终仍应以对应集合实现的 API 规范为准。

不能把 ArrayList 的具体实现规则机械推广到所有集合。


4.4 Iterator.remove 是 Optional Operation

虽然:

Iterator

接口提供:

remove()

但它是:

optional operation

即:

具体 Iterator 实现可以选择不支持。

如果不支持,调用时可能抛出:

UnsupportedOperationException

因此:

Iterator 有 remove 方法

不代表:

所有 Iterator 都一定支持 remove

这和前面 Collection 的 optional operation 思想是一致的。


4.5 不要捕获 CME 后继续“硬跑”

例如:

try {

    for (String name : names) {

        names.remove(name);
    }

} catch (ConcurrentModificationException e) {

    // 忽略
}

这不是解决问题。

正确思路应该是:

为什么修改方式不正确?

↓

选择 Iterator.remove()

或者

正确的索引删除

或者

removeIf()

异常处理不是:

把错误吞掉。

而应该:

消除错误产生的根本原因。


五、实践应用

5.1 删除所有黑名单用户名

例如:

List<String> users =
        new ArrayList<>();

users.add("alice");
users.add("spam_user");
users.add("bob");
users.add("spam_user");

方案:

users.removeIf(
        name -> name.equals("spam_user")
);

5.2 删除包含指定明星的电影

例如:

Iterator<Movie> iterator =
        movies.iterator();

while (iterator.hasNext()) {

    Movie movie =
            iterator.next();

    if (movie.getActor()
             .contains(star)) {

        iterator.remove();
    }
}

这种:

遍历对象
↓
判断业务条件
↓
删除当前对象

就是 Iterator.remove 的典型应用。


5.3 删除不合格成绩

List<Integer> scores =
        new ArrayList<>();

scores.add(95);
scores.add(43);
scores.add(78);
scores.add(20);

scores.removeIf(
        score -> score < 60
);

表达非常直接:

删除所有低于 60 分的数据

六、常见问题

6.1 ConcurrentModificationException 是多线程异常吗?

它经常和并发修改有关,但:

不代表一定发生了多线程。

同一个线程内不正确地:

边迭代
边直接结构性修改集合

也可能触发。


6.2 为什么增强 for 中不能直接 names.remove?

因为增强 for 遍历 Collection 时依赖迭代机制。

你使用:

names.remove(...)

绕过当前迭代器直接修改集合结构,可能破坏迭代器正在依赖的状态。


6.3 为什么 Iterator.remove 可以?

因为:

Iterator

自己负责当前遍历过程。

通过:

iterator.remove()

修改时,迭代器能够按照规定维护自身状态。


6.4 iterator.remove 可以在 next 前调用吗?

不能正常这样使用。

标准顺序:

next()
↓
得到元素
↓
remove()

如果还没有调用:

next()

就调用:

remove()

可能抛出:

IllegalStateException

6.5 为什么普通 for 删除后要 i--?

因为:

删除元素
↓
后续元素向前移动

如果继续:

i++

就可能跳过刚刚移动到当前位置的新元素。

因此:

i--;

抵消下一步的:

i++;

使程序重新检查当前位置。


6.6 为什么倒序遍历不用 i++ 修正?

因为从后向前删除时:

后面的元素删除

不会改变:

前面尚未访问元素的索引

因此更加自然。


6.7 增强 for 和 forEach 是不是“永远不能修改元素”?

要区分:

修改对象自身的普通字段

和

结构性修改集合

例如:

for (Student student : students) {
    student.setScore(100);
}

这里修改的是:

Student 对象内部状态

不等同于:

students.remove(student);

后者改变集合结构。

真正危险的是:

遍历同一个集合时直接改变其结构。


6.8 removeIf 和 Iterator.remove 怎么选?

如果需求非常简单:

删除所有满足条件的数据

优先考虑:

removeIf(...)

如果遍历时还有复杂逻辑:

记录日志

统计数据

多个判断

删除部分元素

Iterator 会更加显式。


6.9 能不能靠“不抛 CME”判断代码正确?

不能。

fail-fast 检测不是绝对保证。

因此:

这次没抛异常

不能推出:

这种修改方式一定合法

必须遵守对应 API 的修改规则。


七、练习与验收

7.1 知识问答

  1. 什么叫遍历集合的同时修改集合结构?
  2. 什么叫结构性修改?
  3. ConcurrentModificationException 是什么?
  4. 为什么 CME 不一定表示发生了多线程?
  5. 什么叫 fail-fast?
  6. fail-fast 是否保证所有非法修改都一定抛出异常?
  7. 为什么不能依赖 CME 判断程序正确性?
  8. 为什么增强 for 中直接调用集合 remove 可能出问题?
  9. 为什么 Iterator 遍历时推荐使用 iterator.remove()
  10. iterator.remove() 删除的是哪个元素?
  11. 为什么不能在 next() 之前直接调用 remove()
  12. 为什么一次 next() 后不能连续正常调用两次 remove()
  13. 正序索引删除后为什么可能需要 i--
  14. 为什么倒序索引删除不容易漏数据?
  15. forEach 中为什么不适合结构性修改正在遍历的原集合?
  16. removeIf 解决什么问题?
  17. fail-fast 和线程安全是什么关系?
  18. 修改集合中对象内部字段与修改集合结构有什么区别?

7.2 代码阅读

阅读:

List<String> names =
        new ArrayList<>();

names.add("张三");
names.add("李四");
names.add("张三");

for (String name : names) {

    if (name.equals("张三")) {
        names.remove(name);
    }
}

回答:

  1. 这段代码的核心问题是什么?
  2. 是否必须存在多个线程才可能出问题?
  3. 可能出现什么异常?
  4. 应该使用什么方式安全删除?

阅读:

List<String> names =
        new ArrayList<>();

names.add("张三");
names.add("张三");
names.add("李四");

for (int i = 0;
     i < names.size();
     i++) {

    if (names.get(i)
             .equals("张三")) {

        names.remove(i);
    }
}

要求:

  1. 第一次删除以后集合变成什么?
  2. 原索引 1 的元素移动到哪里?
  3. 下一轮 i 变成多少?
  4. 哪个元素可能被漏掉?
  5. 如何修复?

阅读:

Iterator<String> iterator =
        names.iterator();

while (iterator.hasNext()) {

    String name =
            iterator.next();

    if (name.equals("张三")) {
        iterator.remove();
    }
}

回答:

  1. next() 负责什么?
  2. remove() 删除的是哪个元素?
  3. 为什么这种方式可以解决典型遍历删除问题?

7.3 手写代码

创建:

List<String>

保存:

Java入门
宁夏枸杞
黑枸杞
人字拖
特级枸杞
枸杞子
西洋参

要求删除全部包含:

枸杞

的元素。

分别实现:

  1. Iterator + remove()
  2. 正序索引 for + i--
  3. 倒序索引 for。
  4. removeIf()

要求四种方式最终得到相同结果。

7.4 Debug

下面代码:

Iterator<String> iterator =
        names.iterator();

while (iterator.hasNext()) {

    String name =
            iterator.next();

    if (name.equals("张三")) {

        names.remove(name);
    }
}

要求:

  1. 找出错误删除方式。
  2. 改成 Iterator 的正确删除 API。
  3. 解释为什么改完后迭代器能够正确维护状态。

下面代码:

Iterator<String> iterator =
        names.iterator();

iterator.remove();

要求:

  1. 说明为什么代码逻辑错误。
  2. 可能出现什么异常?
  3. 正确调用顺序是什么?

下面代码:

Iterator<String> iterator =
        names.iterator();

String name =
        iterator.next();

iterator.remove();

iterator.remove();

要求:

  1. 第一个 remove() 删除什么?
  2. 第二个 remove() 为什么不合法?
  3. 需要做什么操作以后才能再次合法删除新的当前元素?

7.5 综合训练

实现一个“电影封杀演员”功能。

已存在:

class Movie {

    private String name;
    private String actor;

    // 构造器、getter、setter
}

以及:

List<Movie> movies;

需求:

用户输入一个演员姓名

删除所有 actor 中包含该姓名的电影

要求:

  1. 先写出 Iterator 版本。
  2. 再写出倒序索引版本。
  3. 再思考能否使用 removeIf
  4. 分析三种方式的可读性。
  5. 不允许使用增强 for + movies.remove(movie)

7.6 本章验收

不查看资料完成:

  • 能够解释 ConcurrentModificationException
  • 能够明确说明 CME 不等于多线程。
  • 能够解释 fail-fast 的基本思想。
  • 能够解释为什么不能依赖 fail-fast 作为程序正确性保障。
  • 能够闭卷手写 Iterator 安全删除模板。
  • 能够解释 iterator.remove() 的调用约束。
  • 能够手写正序索引 for + i--
  • 能够手写倒序索引删除。
  • 能够使用 removeIf 完成条件删除。
  • 能够判断增强 for / forEach 中直接修改原集合为什么危险。
  • 面对“遍历中删除”的需求时,能够主动选择正确方案。

如果看到遍历删除问题时第一反应仍然是:

for (Element e : collection) {
    collection.remove(e);
}

说明本章还没有真正掌握。