Java 中的 ConcurrentModificationException (CME) 是一种运行时异常,通常发生在多线程或单线程环境下,当一个集合(如 List
、Map
、Set
)在被迭代时被结构性修改(structural modification)时抛出。以下是对 CME 异常的深度分析,包括其原因、触发场景、底层机制、解决方法及预防措施。
1. CME 异常的本质
CME 异常的核心原因是 Java 集合框架中的 快速失败(fail-fast)机制。快速失败是指在迭代过程中,如果检测到集合的结构被意外修改(比如添加、删除元素),迭代器会立即抛出 ConcurrentModificationException
,以避免不一致的状态或不可预期的行为。
-
结构性修改的定义:
- 对于
List
、Set
:添加或删除元素。 - 对于
Map
:添加或删除键值对。 - 注意:某些操作(如修改已有元素的值)通常不被视为结构性修改,不会触发 CME。
- 对于
-
快速失败的实现:
集合类(如ArrayList
、HashMap
)维护一个内部计数器modCount
,记录集合的修改次数。每次进行结构性修改(如add
、remove
),modCount
都会递增。迭代器在创建时会记录当时的modCount
(称为expectedModCount
)。在迭代过程中,迭代器会检查modCount
是否等于expectedModCount
,如果不相等,说明集合被修改,抛出 CME。
2. CME 异常的触发场景
CME 异常可能在以下场景中发生:
2.1 单线程环境
在单线程中,CME 通常发生在使用迭代器(显式或隐式)遍历集合时,同时对集合进行结构性修改。例如:
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
for (String item : list) { // 隐式使用迭代器
list.remove(item); // 结构性修改
}
- 问题:
for-each
循环底层使用Iterator
,在remove
时修改了list
的结构,导致modCount
变化,迭代器检测到不一致,抛出 CME。 - 类似场景:
- 使用显式迭代器(
Iterator
)遍历时,直接调用集合的add
、remove
方法。 - 使用
forEach
方法遍历集合时,修改集合结构。
- 使用显式迭代器(
2.2 多线程环境
在多线程环境下,CME 更容易发生,因为多个线程可能同时访问和修改同一个集合。例如:
List<String> list = new ArrayList<>();
// 线程 1:迭代集合
new Thread(() -> {
for (String item : list) {
System.out.println(item);
}
}).start();
// 线程 2:修改集合
new Thread(() -> {
list.add("C");
}).start();
- 问题:线程 1 在迭代时,线程 2 修改了集合,导致
modCount
变化,触发 CME。 - 类似场景:
- 一个线程遍历
HashMap
,另一个线程添加或删除键值对。 - 多个线程同时对非线程安全的集合(如
ArrayList
、HashMap
)进行读写操作。
- 一个线程遍历
2.3 隐式迭代器场景
某些集合方法内部使用了迭代器,调用这些方法时也可能触发 CME。例如:
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
list.removeIf(item -> {
list.add("C"); // 在 removeIf 内部迭代时修改集合
return true;
});
- 问题:
removeIf
方法内部使用迭代器遍历集合,而在遍历过程中修改集合结构,触发 CME。
3. CME 异常的底层机制
以 ArrayList
为例,分析 CME 的底层实现:
-
关键字段:
ArrayList
维护一个modCount
字段,继承自AbstractList
,用于记录结构性修改次数。- 每次调用
add
、remove
等方法时,modCount++
。
-
迭代器的实现:
ArrayList
的迭代器(Itr
类)在构造时会记录当前的modCount
作为expectedModCount
。在next()
或hasNext()
方法中,迭代器会调用checkForComodification()
:final void checkForComodification() { if (modCount != expectedModCount) throw new ConcurrentModificationException(); }
-
触发条件:
如果在迭代过程中,modCount
被其他操作(如add
、remove
)修改,导致modCount != expectedModCount
,迭代器抛出 CME。 -
其他集合:
HashMap
、HashSet
等也有类似机制,维护modCount
并在迭代器中检查。TreeMap
、TreeSet
由于底层红黑树结构,结构性修改的影响更复杂,但也会触发 CME。
4. 为什么设计快速失败机制?
快速失败机制是为了在并发或不当操作时尽早暴露问题,避免潜在的 bug 或数据不一致。原因包括:
- 数据一致性:迭代过程中修改集合可能导致迭代器访问到不一致的状态(如跳过元素或重复访问)。
- 调试方便:CME 明确提示开发者集合被不当修改,便于定位问题。
- 性能优化:快速失败避免了更复杂的同步机制,保持了非线程安全集合的高性能。
5. 解决和预防 CME 的方法
根据单线程和多线程场景,解决 CME 的方法不同:
5.1 单线程环境
-
使用迭代器的
remove
方法:
迭代器提供的remove
方法会在删除元素时同步更新expectedModCount
,避免 CME。例如:List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C")); Iterator<String> iterator = list.iterator(); while (iterator.hasNext()) { String item = iterator.next(); if (item.equals("B")) { iterator.remove(); // 安全删除 } }
-
使用索引遍历(避免迭代器):
对于ArrayList
等支持随机访问的集合,可以使用普通for
循环和索引操作,但需注意边界问题:List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C")); for (int i = 0; i < list.size(); i++) { if (list.get(i).equals("B")) { list.remove(i); i--; // 调整索引 } }
- 注意:删除元素后需调整索引,否则可能跳过元素。
-
收集要删除的元素:
先遍历收集需要删除的元素,然后再统一删除:List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C")); List<String> toRemove = new ArrayList<>(); for (String item : list) { if (item.equals("B")) { toRemove.add(item); } } list.removeAll(toRemove);
-
使用流或函数式操作:
使用 Java 8 的 Stream API 避免直接迭代:List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C")); list = list.stream() .filter(item -> !item.equals("B")) .collect(Collectors.toList());
5.2 多线程环境
-
使用线程安全集合:
使用 Java 提供的线程安全集合,如:CopyOnWriteArrayList
:每次修改创建新副本,适合读多写少场景。ConcurrentHashMap
:支持并发读写,迭代时不会抛出 CME。Collections.synchronizedList
:通过同步包装非线程安全集合。
示例:
List<String> list = new CopyOnWriteArrayList<>(Arrays.asList("A", "B")); new Thread(() -> { for (String item : list) { System.out.println(item); } }).start(); new Thread(() -> { list.add("C"); // 不会触发 CME }).start();
-
显式同步:
使用synchronized
块或锁保护集合的读写操作:List<String> list = Collections.synchronizedList(new ArrayList<>(Arrays.asList("A", "B"))); new Thread(() -> { synchronized (list) { for (String item : list) { System.out.println(item); } } }).start(); new Thread(() -> { synchronized (list) { list.add("C"); } }).start();
-
避免共享可变集合:
在多线程环境中,尽量避免多个线程共享同一个可变集合。可以使用不可变集合(如Collections.unmodifiableList
)或将集合的修改限制在单一线程。
5.3 其他预防措施
- 代码审查:检查迭代代码,确保没有在迭代时直接修改集合。
- 日志记录:在开发环境中捕获 CME,记录堆栈信息,便于调试。
- 单元测试:编写测试用例,模拟多线程访问集合,验证代码的健壮性。
6. CME 的性能和权衡
- 快速失败的性能优势:
非线程安全集合(如ArrayList
、HashMap
)性能优于线程安全集合(如Vector
、Hashtable
),因为它们不加锁。快速失败机制以较低的成本提供了基本的安全检查。 - 线程安全集合的代价:
CopyOnWriteArrayList
:写操作开销大,适合读多写少场景。ConcurrentHashMap
:虽然高效,但仍需理解其并发模型(如分段锁)。- 同步集合(如
Collections.synchronizedList
):锁竞争可能导致性能瓶颈。
开发者需根据应用场景选择合适的集合和并发控制策略。
7. 常见误区
-
误以为 CME 只发生在多线程环境:
CME 在单线程中也很常见,尤其是在使用for-each
或removeIf
时修改集合。 -
误以为所有修改都会触发 CME:
修改元素的值(非结构性修改)通常不会触发 CME。例如:List<String> list = new ArrayList<>(Arrays.asList("A", "B")); for (String item : list) { list.set(list.indexOf(item), item + "_modified"); // 不会触发 CME }
-
忽略隐式迭代器:
某些方法(如toString
、removeIf
)可能隐式使用迭代器,开发者需仔细检查。
8. 总结
ConcurrentModificationException 是 Java 集合框架中快速失败机制的表现,旨在保护集合在迭代过程中的一致性。其触发原因主要包括:
- 单线程中迭代时直接修改集合结构。
- 多线程中多个线程并发访问和修改集合。
解决方法包括使用迭代器安全删除、线程安全集合、显式同步等。开发者应根据场景选择合适的策略,并在开发中注重代码审查和测试,以避免 CME 的发生。