并发修改异常与遍历中的安全删除 | JavaSE
并发修改异常与遍历中的安全删除
一、学习目标
完成本章后,你应该能够:
- 能够解释什么是遍历过程中的结构性修改。
- 能够理解
ConcurrentModificationException的含义,并知道它不一定意味着发生了多线程并发。 - 能够解释为什么增强 for 遍历期间直接调用集合自身的
add、remove等操作可能出问题。 - 能够使用
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 知识问答
- 什么叫遍历集合的同时修改集合结构?
- 什么叫结构性修改?
ConcurrentModificationException是什么?- 为什么 CME 不一定表示发生了多线程?
- 什么叫 fail-fast?
- fail-fast 是否保证所有非法修改都一定抛出异常?
- 为什么不能依赖 CME 判断程序正确性?
- 为什么增强 for 中直接调用集合
remove可能出问题? - 为什么 Iterator 遍历时推荐使用
iterator.remove()? iterator.remove()删除的是哪个元素?- 为什么不能在
next()之前直接调用remove()? - 为什么一次
next()后不能连续正常调用两次remove()? - 正序索引删除后为什么可能需要
i--? - 为什么倒序索引删除不容易漏数据?
forEach中为什么不适合结构性修改正在遍历的原集合?removeIf解决什么问题?- fail-fast 和线程安全是什么关系?
- 修改集合中对象内部字段与修改集合结构有什么区别?
7.2 代码阅读
阅读:
List<String> names =
new ArrayList<>();
names.add("张三");
names.add("李四");
names.add("张三");
for (String name : names) {
if (name.equals("张三")) {
names.remove(name);
}
}
回答:
- 这段代码的核心问题是什么?
- 是否必须存在多个线程才可能出问题?
- 可能出现什么异常?
- 应该使用什么方式安全删除?
阅读:
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 的元素移动到哪里?
- 下一轮
i变成多少? - 哪个元素可能被漏掉?
- 如何修复?
阅读:
Iterator<String> iterator =
names.iterator();
while (iterator.hasNext()) {
String name =
iterator.next();
if (name.equals("张三")) {
iterator.remove();
}
}
回答:
next()负责什么?remove()删除的是哪个元素?- 为什么这种方式可以解决典型遍历删除问题?
7.3 手写代码
创建:
List<String>
保存:
Java入门
宁夏枸杞
黑枸杞
人字拖
特级枸杞
枸杞子
西洋参
要求删除全部包含:
枸杞
的元素。
分别实现:
- Iterator +
remove()。 - 正序索引 for +
i--。 - 倒序索引 for。
removeIf()。
要求四种方式最终得到相同结果。
7.4 Debug
下面代码:
Iterator<String> iterator =
names.iterator();
while (iterator.hasNext()) {
String name =
iterator.next();
if (name.equals("张三")) {
names.remove(name);
}
}
要求:
- 找出错误删除方式。
- 改成 Iterator 的正确删除 API。
- 解释为什么改完后迭代器能够正确维护状态。
下面代码:
Iterator<String> iterator =
names.iterator();
iterator.remove();
要求:
- 说明为什么代码逻辑错误。
- 可能出现什么异常?
- 正确调用顺序是什么?
下面代码:
Iterator<String> iterator =
names.iterator();
String name =
iterator.next();
iterator.remove();
iterator.remove();
要求:
- 第一个
remove()删除什么? - 第二个
remove()为什么不合法? - 需要做什么操作以后才能再次合法删除新的当前元素?
7.5 综合训练
实现一个“电影封杀演员”功能。
已存在:
class Movie {
private String name;
private String actor;
// 构造器、getter、setter
}
以及:
List<Movie> movies;
需求:
用户输入一个演员姓名
删除所有 actor 中包含该姓名的电影
要求:
- 先写出 Iterator 版本。
- 再写出倒序索引版本。
- 再思考能否使用
removeIf。 - 分析三种方式的可读性。
- 不允许使用增强 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);
}
说明本章还没有真正掌握。