1. 现象复现:为什么循环中修改集合会报错?
让我们从一个实际案例开始。假设你正在开发一个简单的编程语言学习应用,需要从语言列表中移除某些不再维护的项目。你可能会写出这样的代码:
java复制List<String> languages = new ArrayList<>();
languages.add("Java");
languages.add("Python");
languages.add("Go");
languages.add("C++");
// 尝试移除Python
for (String lang : languages) {
if ("Python".equals(lang)) {
languages.remove(lang); // 这里会抛出ConcurrentModificationException
}
}
运行这段代码时,控制台会立即抛出ConcurrentModificationException异常。这个异常的字面意思是"并发修改异常",但奇怪的是,我们的代码明明是在单线程环境下运行的。
注意:即使是在单线程环境下,这种操作也会触发CME异常。很多开发者误以为这是多线程特有的问题,其实不然。
2. 底层原理:迭代器的"快速失败"机制
2.1 增强for循环的本质
首先需要明白,Java中的增强for循环(for-each循环)实际上是通过迭代器实现的。上面的代码等价于:
java复制Iterator<String> iterator = languages.iterator();
while (iterator.hasNext()) {
String lang = iterator.next();
if ("Python".equals(lang)) {
languages.remove(lang); // 问题出在这里
}
}
2.2 modCount机制解析
ArrayList内部维护了一个modCount(修改计数器)字段。每次对集合进行结构性修改(如add/remove)时,这个计数器都会递增。迭代器在创建时会记录当前的modCount值(expectedModCount),并在每次操作前检查这个值是否被修改。
java复制final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
当我们直接调用list.remove()时,modCount会增加,但迭代器内部的expectedModCount不会更新,导致两者不一致,从而抛出异常。
3. 正确解决方案:四种安全删除方式
3.1 使用迭代器的remove方法
最规范的解决方案是使用迭代器自身的remove方法:
java复制Iterator<String> iterator = languages.iterator();
while (iterator.hasNext()) {
String lang = iterator.next();
if ("Python".equals(lang)) {
iterator.remove(); // 正确方式
}
}
这种方法之所以安全,是因为迭代器的remove()方法会在删除元素后同步更新expectedModCount:
java复制public void remove() {
// ...
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount; // 关键:同步修改计数
}
3.2 倒序普通for循环
对于ArrayList这类随机访问集合,可以使用普通for循环倒序遍历:
java复制for (int i = languages.size() - 1; i >= 0; i--) {
if ("Python".equals(languages.get(i))) {
languages.remove(i);
}
}
这种方法有效是因为:
- 倒序删除不会影响未遍历元素的索引
- 没有使用迭代器,避开了modCount检查
警告:这种方法不适用于LinkedList,因为它的随机访问性能很差。
3.3 Java 8+的removeIf方法
Java 8引入了更简洁的写法:
java复制languages.removeIf(lang -> "Python".equals(lang));
3.4 使用Stream过滤(Java 8+)
对于需要同时过滤和转换的场景,可以使用Stream:
java复制List<String> filtered = languages.stream()
.filter(lang -> !"Python".equals(lang))
.collect(Collectors.toList());
4. 多线程环境下的特殊处理
虽然CME在单线程就会出现,但在多线程环境下风险更高。这时需要考虑线程安全的集合:
4.1 CopyOnWriteArrayList
适合读多写少的场景:
java复制List<String> safeList = new CopyOnWriteArrayList<>(languages);
for (String lang : safeList) {
if ("Python".equals(lang)) {
safeList.remove(lang); // 安全
}
}
原理:每次修改时创建底层数组的新副本,迭代器持有的是不变的快照。
4.2 Collections.synchronizedList
需要配合同步块使用:
java复制List<String> syncList = Collections.synchronizedList(languages);
synchronized (syncList) {
Iterator<String> it = syncList.iterator();
while (it.hasNext()) {
String lang = it.next();
if ("Python".equals(lang)) {
it.remove();
}
}
}
5. 常见陷阱与最佳实践
5.1 三种绝对要避免的操作
-
增强for循环中直接调用集合的remove/add
java复制for (String item : list) { list.remove(item); // 错误! } -
使用迭代器遍历时通过集合修改
java复制Iterator<String> it = list.iterator(); while (it.hasNext()) { list.remove(0); // 错误! it.next(); } -
多线程环境下不加锁直接修改
java复制// 线程1 for (String item : sharedList) { // 线程2同时修改sharedList }
5.2 性能考量
| 方法 | 时间复杂度 | 适用场景 |
|---|---|---|
| 迭代器remove | O(n) | 通用方案 |
| 倒序for循环 | O(n²) | 仅ArrayList小数据集 |
| removeIf | O(n) | Java8+简洁代码 |
| Stream过滤 | O(n) | 需要新集合时 |
5.3 我的实战经验
在实际项目中,我总结出以下经验:
- 优先使用迭代器的remove方法,这是最规范的做法
- 对于需要复杂条件判断的删除,Java8的removeIf会让代码更清晰
- 在多线程环境下,CopyOnWriteArrayList的性能往往比加锁方案更好
- 使用Stream时要注意,它会产生新集合,可能增加内存开销
一个容易忽略的细节:使用Arrays.asList()创建的列表不支持结构性修改(任何add/remove都会抛UnsupportedOperationException),需要特别注意。
6. 扩展知识:其他集合类的表现
6.1 HashMap的迭代器
HashMap的迭代器同样遵循快速失败原则。以下代码会抛出CME:
java复制Map<String, Integer> map = new HashMap<>();
map.put("A", 1);
map.put("B", 2);
for (String key : map.keySet()) {
if ("A".equals(key)) {
map.remove(key); // 抛出异常
}
}
安全删除方式:
java复制Iterator<Map.Entry<String, Integer>> it = map.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, Integer> entry = it.next();
if ("A".equals(entry.getKey())) {
it.remove(); // 正确
}
}
6.2 ConcurrentHashMap的特殊性
ConcurrentHashMap的迭代器是弱一致性的,不会抛出CME:
java复制ConcurrentHashMap<String, Integer> concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put("A", 1);
concurrentMap.put("B", 2);
for (String key : concurrentMap.keySet()) {
if ("A".equals(key)) {
concurrentMap.remove(key); // 安全
}
}
但要注意,这种删除方式不能保证原子性,在极端并发情况下可能需要额外同步。
7. 调试技巧:如何快速定位CME问题
当遇到CME异常时,可以按照以下步骤排查:
- 检查异常堆栈,找到触发异常的集合操作位置
- 确认是否在迭代过程中直接修改了集合
- 检查是否有其他线程可能并发修改了集合
- 使用Collections.synchronizedList等线程安全包装器时,确认是否正确使用了同步块
一个有用的技巧:在开发阶段,可以使用Collections.checkedList等包装器来尽早发现问题:
java复制List<String> checkedList = Collections.checkedList(new ArrayList<>(), String.class);
这种包装器会在类型不匹配时立即抛出ClassCastException,而不是等到后续操作才报错。