java.util.ConcurrentModificationException
是Java中一个常见的运行时异常,它通常发生在使用迭代器(Iterator)遍历一个集合(如 ArrayList
, HashMap
等)的过程中,同时该集合的结构被其他方式(非迭代器自身的 remove()
或 add()
方法)修改了。这种修改可能是添加、删除元素等操作。值得注意的是,这个异常并不仅限于多线程环境,单线程中不当的集合操作也极易触发。本文将从“小白”视角出发,深入剖析此异常的根源——迭代器的“快速失败”(Fail-Fast)机制,详细演示导致异常的各种场景(包括增强型for循环的“陷阱”),并提供一套在单线程和多线程环境下安全修改集合的实用策略,包括正确使用迭代器方法、Java 8的Stream API、removeIf
以及线程安全的并发集合等。
你好,我是默语。在使用Java集合(如 List
, Set
, Map
)进行编程时,我们经常需要遍历它们并可能在遍历过程中根据某些条件修改集合中的元素。然而,如果你在遍历时直接调用集合的 add()
或 remove()
方法,很可能就会遭遇一个令人困惑的“不速之客”——java.util.ConcurrentModificationException
。
对于初学者来说,这个异常的名字听起来像是“并发修改”导致的,很容易误以为它只在多线程环境下才会出现。但实际上,即使在单线程程序中,不恰当的迭代期修改集合也会导致这个异常。它更准确的含义是“在迭代器认为不应该发生修改的时候,集合被修改了”。
这个异常的抛出,其实是Java集合框架中一种被称为“快速失败”(Fail-Fast)的设计策略。它旨在尽早地暴露潜在的并发问题或逻辑错误,避免在未来产生更难以追踪的数据不一致或不确定行为。
本篇博客的目标,就是带你这位“小白”朋友,彻底搞懂 ConcurrentModificationException
为何会发生,以及在各种场景下,我们应该如何安全、优雅地在遍历集合的同时修改它。让我们一起扫除这个迭代过程中的“雷区”吧!
默语是谁?
大家好,我是 默语,别名默语博主,擅长的技术领域包括Java、运维和人工智能。我的技术背景扎实,涵盖了从后端开发到前端框架的各个方面,特别是在Java 性能优化、多线程编程、算法优化等领域有深厚造诣。
目前,我活跃在CSDN、掘金、阿里云和 51CTO等平台,全网拥有超过15万的粉丝,总阅读量超过1400 万。统一 IP 名称为 默语 或者 默语博主。我是 CSDN 博客专家、阿里云专家博主和掘金博客专家,曾获博客专家、优秀社区主理人等多项荣誉,并在 2023 年度博客之星评选中名列前 50。我还是 Java 高级工程师、自媒体博主,北京城市开发者社区的主理人,拥有丰富的项目开发经验和产品设计能力。希望通过我的分享,帮助大家更好地了解和使用各类技术产品,在不断的学习过程中,可以帮助到更多的人,结交更多的朋友.
我的博客内容涵盖广泛,主要分享技术教程、Bug解决方案、开发工具使用、前沿科技资讯、产品评测与使用体验。我特别关注云服务产品评测、AI 产品对比、开发板性能测试以及技术报告,同时也会提供产品优缺点分析、横向对比,并分享技术沙龙与行业大会的参会体验。我的目标是为读者提供有深度、有实用价值的技术洞察与分析。
java.util.ConcurrentModificationException
:Java集合迭代时修改的“雷区”与安全操作指南(小白必看)ConcurrentModificationException
初体验 —— 它为何而来?让我们先看一个简单的例子,它会在单线程环境中触发这个异常。
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class CME_Demo_Simple {
public static void main(String[] args) {
List<String> fruits = new ArrayList<>(Arrays.asList("Apple", "Banana", "Cherry", "Date"));
System.out.println("原始列表: " + fruits);
try {
// 场景1:使用增强型 for 循环遍历时,直接调用 list.remove()
for (String fruit : fruits) {
System.out.println("当前水果: " + fruit);
if ("Banana".equals(fruit)) {
fruits.remove(fruit); // 尝试在迭代时修改列表结构
}
}
} catch (java.util.ConcurrentModificationException e) {
System.err.println("\n捕获到 ConcurrentModificationException (增强型for循环)!");
System.err.println("异常信息: " + e.getMessage()); // 通常为 null
e.printStackTrace(System.err);
}
// 重置列表,演示显式使用迭代器
fruits = new ArrayList<>(Arrays.asList("Apple", "Banana", "Cherry", "Date"));
System.out.println("\n重置后列表: " + fruits);
try {
// 场景2:显式使用迭代器遍历时,直接调用 list.add()
java.util.Iterator<String> iterator = fruits.iterator();
while (iterator.hasNext()) {
String fruit = iterator.next();
System.out.println("当前水果 (迭代器): " + fruit);
if ("Cherry".equals(fruit)) {
fruits.add("Elderberry"); // 尝试在迭代时修改列表结构
}
}
} catch (java.util.ConcurrentModificationException e) {
System.err.println("\n捕获到 ConcurrentModificationException (显式迭代器)!");
System.err.println("异常信息: " + e.getMessage());
e.printStackTrace(System.err);
}
System.out.println("\n最终列表状态(可能不符合预期): " + fruits);
}
}
当你运行这段代码,你会发现在尝试删除 “Banana” 或添加 “Elderberry” 的时候,程序会抛出 ConcurrentModificationException
。异常的堆栈跟踪会指向你调用 fruits.remove(fruit)
或 fruits.add("Elderberry")
的那一行。
那么,为什么Java会这么“严格”呢?这就涉及到迭代器的工作方式和“快速失败”机制了。
java.util.Iterator
) 是一个接口,它提供了一种统一的方式来顺序访问集合(如 List
, Set
, Map
的键集/值集/条目集)中的元素,而无需暴露该集合的内部结构。boolean hasNext()
: 判断集合中是否还有下一个元素可供访问。E next()
: 返回集合中的下一个元素,并将迭代器的“指针”后移。void remove()
: (可选操作)从集合中移除 next()
方法最后返回的那个元素。ArrayList
, LinkedList
, HashSet
, HashMap
的迭代器)都采用了快速失败 (Fail-Fast) 机制。remove()
或 add()
方法,如果迭代器支持 add()
)修改了,迭代器就会立即抛出 ConcurrentModificationException
。ArrayList
为例简化说明): ArrayList
内部维护一个名为 modCount
(modification count,修改计数器) 的整型变量。每当列表的结构发生改变(如调用 add()
, remove()
, clear()
等方法),modCount
的值就会增加。list.iterator()
获取一个迭代器时,这个迭代器会记录下当前列表的 modCount
值,并将其存储在迭代器内部的一个变量中(比如叫 expectedModCount
)。next()
或 checkForComodification()
(remove()
方法内部也会调用)方法时,迭代器会比较它自己保存的 expectedModCount
和列表当前的 modCount
。 expectedModCount == modCount
,说明列表在迭代器创建后没有被外部修改,迭代可以安全继续。expectedModCount != modCount
,说明列表结构在迭代器不知情的情况下被修改了!此时,为了避免可能出现的不可预期的行为(比如跳过元素、访问到已删除的元素导致错误、或者无限循环等),迭代器会选择“快速失败”,立即抛出 ConcurrentModificationException
。即使在单线程环境中,如果你不遵循迭代器的规则,也很容易踩到 ConcurrentModificationException
的雷。
为什么增强型for循环 (Enhanced For-Loop) 也会中招? 很多初学者喜欢用增强型for循环,因为它简洁明了:
for (String fruit : fruits) {
// ...
}
你需要知道的是,Java编译器在处理增强型for循环时,实际上会将其转换为使用迭代器的代码。所以,上面的代码本质上等价于:
java.util.Iterator<String> iterator = fruits.iterator();
while (iterator.hasNext()) {
String fruit = iterator.next();
// ...
}
因此,如果在增强型for循环的循环体内直接调用 fruits.remove(fruit)
或 fruits.add(...)
,同样会因为外部修改导致迭代器内部的 expectedModCount
与集合的 modCount
不一致,从而触发异常。
安全修改集合的方法 (Safe Ways to Modify Collections During Iteration in Single Thread):
那么,如何在遍历时安全地修改集合呢?以下是几种常用且安全的方法:
a. 使用迭代器的 remove()
方法:
这是唯一推荐的、在迭代过程中通过迭代器本身删除元素的安全方式。iterator.remove()
方法会正确地更新迭代器和集合的状态(包括 modCount
和 expectedModCount
),因此不会抛出异常。
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
public class SafeRemove_Iterator {
public static void main(String[] args) {
List<String> fruits = new ArrayList<>(Arrays.asList("Apple", "Banana", "Cherry", "Banana", "Date"));
System.out.println("原始列表: " + fruits);
Iterator<String> iterator = fruits.iterator();
while (iterator.hasNext()) {
String fruit = iterator.next(); // 必须先调用 next()
if ("Banana".equals(fruit)) {
iterator.remove(); // 安全地删除由 next() 返回的最后一个元素
System.out.println("移除了: Banana");
}
}
System.out.println("修改后列表: " + fruits); // 输出: [Apple, Cherry, Date]
}
}
注意:必须在调用 iterator.next()
之后,并且在下一次调用 iterator.next()
之前,才能调用 iterator.remove()
。每次 next()
之后,remove()
最多只能调用一次。
b. 使用普通for循环配合索引(需非常小心):
如果你非要用索引来操作,尤其是在删除元素时,需要特别注意。因为删除一个元素后,后面所有元素的索引都会向前移动一位,列表的 size()
也会减小。
错误的方式(正向遍历删除):
// 这种方式在删除时很容易跳过元素或导致索引越界
// for (int i = 0; i < fruits.size(); i++) {
// if ("Banana".equals(fruits.get(i))) {
// fruits.remove(i); // 移除后,i+1 的元素到了 i 的位置,下次循环 i++ 会跳过这个元素
// }
// }
相对安全的方式:反向遍历删除 从列表末尾开始向前遍历。这样,当你删除一个元素时,只会影响已经遍历过的部分的索引,不会影响接下来要遍历的元素的索引。
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class SafeRemove_ReverseLoop {
public static void main(String[] args) {
List<String> fruits = new ArrayList<>(Arrays.asList("Apple", "Banana", "Cherry", "Banana", "Date"));
System.out.println("原始列表: " + fruits);
for (int i = fruits.size() - 1; i >= 0; i--) {
if ("Banana".equals(fruits.get(i))) {
fruits.remove(i);
System.out.println("移除了索引 " + i + " 处的 Banana");
}
}
System.out.println("修改后列表: " + fruits); // 输出: [Apple, Cherry, Date]
}
}
修正正向遍历删除(不推荐,易错):
如果在正向遍历中删除,每次删除后需要将索引 i
减1。
// for (int i = 0; i < fruits.size(); i++) {
// if ("Banana".equals(fruits.get(i))) {
// fruits.remove(i);
// i--; // 回退索引
// }
// }
// 这种方式逻辑复杂,不推荐。
c. 先收集,后操作 (Collect then operate): 这是一种非常通用且安全的方法。首先遍历集合,将需要删除(或添加)的元素收集到一个临时的辅助集合中。等遍历结束后,再根据这个辅助集合对原集合进行批量的添加或删除操作。
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class SafeRemove_CollectThenModify {
public static void main(String[] args) {
List<String> fruits = new ArrayList<>(Arrays.asList("Apple", "Banana", "Cherry", "Date", "Banana"));
System.out.println("原始列表: " + fruits);
List<String> itemsToRemove = new ArrayList<>();
// 第一遍:收集需要删除的元素
for (String fruit : fruits) {
if ("Banana".equals(fruit)) {
itemsToRemove.add(fruit);
}
}
// 第二遍:执行删除操作
if (!itemsToRemove.isEmpty()) {
fruits.removeAll(itemsToRemove); // 批量删除
System.out.println("需要移除的元素: " + itemsToRemove);
}
System.out.println("修改后列表: " + fruits); // 输出: [Apple, Cherry, Date]
}
}
对于添加操作也是类似的,先收集要添加的,遍历完再 addAll()
。
d. 使用 Java 8+ Stream API: Java 8 引入的 Stream API 提供了非常强大的集合处理能力,并且通常是函数式的、无副作用的(它们返回新的集合而不是修改原集合)。
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class SafeRemove_StreamAPI {
public static void main(String[] args) {
List<String> fruits = new ArrayList<>(Arrays.asList("Apple", "Banana", "Cherry", "Date", "Banana"));
System.out.println("原始列表: " + fruits);
// 创建一个不包含 "Banana" 的新列表
List<String> filteredFruits = fruits.stream()
.filter(fruit -> !"Banana".equals(fruit))
.collect(Collectors.toList());
System.out.println("过滤后列表 (新列表): " + filteredFruits); // 输出: [Apple, Cherry, Date]
System.out.println("原列表未改变: " + fruits); // 输出: [Apple, Banana, Cherry, Date, Banana]
}
}
如果你确实想修改原列表,可以在此基础上将 filteredFruits
赋值回 fruits
,或者使用 removeIf
。
e. 使用 List.removeIf()
(Java 8+):
List
接口在 Java 8 中新增了 removeIf(Predicate filter)
方法,它允许你根据一个条件(Predicate)来移除集合中的元素。这个方法内部会正确处理迭代和删除,非常简洁且安全。
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class SafeRemove_RemoveIf {
public static void main(String[] args) {
List<String> fruits = new ArrayList<>(Arrays.asList("Apple", "Banana", "Cherry", "Date", "Banana"));
System.out.println("原始列表: " + fruits);
//移除所有 "Banana"
boolean removed = fruits.removeIf(fruit -> "Banana".equals(fruit));
if (removed) {
System.out.println("已执行 removeIf 操作。");
}
System.out.println("修改后列表: " + fruits); // 输出: [Apple, Cherry, Date]
}
}
到目前为止,我们讨论的都是单线程场景。当多个线程同时访问并可能修改同一个非线程安全的集合(如 ArrayList
, HashMap
)时,问题会变得更加复杂。
ConcurrentModificationException
在多线程中的场景:
ArrayList
,而此时另一个线程调用了这个 ArrayList
的 add()
或 remove()
方法,那么正在迭代的线程在下一次调用迭代器的 next()
或 remove()
时,很可能会因为 modCount
的变化而抛出 ConcurrentModificationException
。简单的同步包装器:Collections.synchronizedList()
等
Java 提供了 Collections
工具类,可以将普通的非线程安全集合包装成线程安全的版本:
Collections.synchronizedList(new ArrayList<>())
Collections.synchronizedSet(new HashSet<>())
Collections.synchronizedMap(new HashMap<>())
这些包装器通过在每个公共方法(如 add
, get
, remove
, size
等)上添加 synchronized
关键字来实现线程安全,保证了单个操作的原子性。但是,对于迭代操作,仅仅使用同步包装器是不够的!
虽然集合本身的方法是同步的,但迭代过程(hasNext()
和 next()
的多次调用)并非一个原子操作。如果在迭代期间,其他线程仍然可以修改集合(即使是通过同步方法),迭代器仍然可能检测到并发修改。
因此,如果你需要在多线程环境下迭代一个由 Collections.synchronizedXxx
包装的集合,并且可能有其他线程会修改它,你必须在迭代期间手动对集合对象本身进行同步:
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Iterator;
public class SynchronizedListIteration {
public static void main(String[] args) {
List<String> syncFruits = Collections.synchronizedList(new ArrayList<>(Arrays.asList("Apple", "Banana")));
// 假设这是线程A的迭代操作
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " 开始迭代");
// 必须在迭代期间对 syncFruits 对象加锁
synchronized (syncFruits) {
Iterator<String> iterator = syncFruits.iterator();
while (iterator.hasNext()) {
String fruit = iterator.next();
System.out.println(Thread.currentThread().getName() + " 迭代到: " + fruit);
try {
Thread.sleep(100); // 模拟耗时操作
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
System.out.println(Thread.currentThread().getName() + " 结束迭代");
}, "Thread-A").start();
// 假设这是线程B的修改操作
new Thread(() -> {
try {
Thread.sleep(50); // 确保线程A已经开始迭代
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println(Thread.currentThread().getName() + " 尝试添加 Cherry");
syncFruits.add("Cherry"); // 这个add方法本身是同步的
System.out.println(Thread.currentThread().getName() + " 添加 Cherry 完毕, 列表: " + syncFruits);
}, "Thread-B").start();
}
}
在上面的例子中,线程A的迭代块被 synchronized(syncFruits)
包围,这样在线程A迭代时,线程B的 syncFruits.add("Cherry")
操作(虽然 add
方法本身是同步的)将会等待线程A释放锁,从而避免了 ConcurrentModificationException
。如果没有这个外部同步块,即使 add
是同步的,迭代器也可能在其两次 next()
调用之间检测到 modCount
的变化。
并发集合 (Concurrent Collections from java.util.concurrent
):
对于高并发场景,java.util.concurrent
包提供了更高级、性能通常也更好的线程安全集合。这些集合为并发访问和修改做了专门优化。
关键特性:它们的迭代器通常是弱一致性 (Weakly Consistent) 或 快照式 (Snapshot) 的,这意味着它们一般不会抛出 ConcurrentModificationException
。
CME
,但遍历时看到的数据可能不是最新的。常用并发集合示例:
CopyOnWriteArrayList<E>
:
List
实现。它的核心思想是“写时复制”(Copy-On-Write)。add
, set
, remove
)都会创建一个底层数组的新副本,修改发生在新副本上,然后用新副本替换旧副本。这个过程是加锁的。get
, iterator
)则不加锁,直接访问当前的数组副本。CME
。import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.Iterator;
public class CopyOnWriteListDemo {
public static void main(String[] args) throws InterruptedException {
List<String> cowList = new CopyOnWriteArrayList<>();
cowList.add("Alpha");
cowList.add("Bravo");
System.out.println("初始列表: " + cowList);
// 线程1:迭代列表
Thread thread1 = new Thread(() -> {
Iterator<String> iter = cowList.iterator(); // 获取迭代器 (快照)
// 在获取迭代器后,主线程再修改列表
try {
Thread.sleep(50); // 确保主线程的修改发生在迭代器创建之后,但在迭代过程中
} catch (InterruptedException e) { e.printStackTrace(); }
System.out.print(Thread.currentThread().getName() + " 迭代结果: ");
while (iter.hasNext()) {
System.out.print(iter.next() + " "); // 迭代的是快照,看不到 "Charlie"
}
System.out.println();
}, "IteratorThread");
thread1.start();
// 主线程(或线程2):在迭代器创建后修改列表
Thread.sleep(10); // 确保迭代器已创建
cowList.add("Charlie");
System.out.println("主线程修改后列表: " + cowList);
cowList.remove("Alpha");
System.out.println("主线程再次修改后列表: " + cowList);
thread1.join(); // 等待迭代线程结束
System.out.println("最终列表: " + cowList);
// IteratorThread 迭代结果: Alpha Bravo
// 主线程修改后列表: [Alpha, Bravo, Charlie]
// 主线程再次修改后列表: [Bravo, Charlie]
// 最终列表: [Bravo, Charlie]
}
}
ConcurrentHashMap<K, V>
: HashMap
实现。它通过分段锁(或在Java 8+中使用更细粒度的CAS操作)等技术来允许多个线程同时进行读写操作,性能远超 Collections.synchronizedMap(new HashMap<>())
。CME
,并且可能(但不保证)反映迭代器创建后的一些修改。ConcurrentLinkedQueue<E>
: BlockingQueue<E>
接口及其实现 (如 ArrayBlockingQueue
, LinkedBlockingQueue
, PriorityBlockingQueue
, SynchronousQueue
): put
和 take
方法。它们的迭代器行为与具体实现有关,但通常也是设计为线程安全的。选择并发集合的考量:你需要根据具体的业务需求(如读写频率、数据量大小、是否需要阻塞行为等)来选择最合适的并发集合。
java.util.ConcurrentModificationException
这个名字虽然带有“并发”二字,但它绝非多线程的专利。它是Java集合框架中“快速失败”迭代器在检测到意外结构修改时抛出的信号。
核心要点回顾:
modCount
),并在每次操作时检查该值是否与集合当前的 modCount
一致。不一致则抛出 CME
。Iterator.remove()
:这是迭代过程中删除元素的标准、安全方式。List.removeIf()
:根据条件删除元素的简洁、安全方式。ArrayList
)在多线程下并发修改极易出问题(CME
只是其中一种表现)。Collections.synchronizedXxx
包装器提供了基本的线程安全,但迭代时仍需外部同步整个迭代块。java.util.concurrent
包中的并发集合(如 CopyOnWriteArrayList
, ConcurrentHashMap
),它们的迭代器通常不会抛出 CME
,并为并发访问提供了更好的性能和设计。当你遇到 ConcurrentModificationException
时,首先冷静分析是在单线程还是多线程环境下,然后回顾是哪种不当的修改方式触发了它。掌握了本文介绍的安全操作方法,你就能有效地规避这个“雷区”,编写出更健壮、更可靠的Java代码。
祝你在Java的世界里游刃有余,代码行云流水!
java.util.ConcurrentModificationException
java.util.Iterator
java.util.concurrent
package summary (并发集合包概述)ConcurrentModificationException
(一篇关于此异常的优秀教程)ConcurrentModificationException
(对该异常的清晰解释)