Java集合框架ConcurrentModificationException产生原因与解决方案

Java集合框架ConcurrentModificationException:原因、诊断与解决方案

大家好,今天我们来深入探讨Java集合框架中一个常见且令人头疼的异常:ConcurrentModificationException(简称CME)。 很多人在多线程环境下操作集合时都会遇到它,但CME并非总是与多线程直接相关。理解CME的根本原因和解决方法对于编写健壮、可靠的Java代码至关重要。

1. CME的定义与基本场景

ConcurrentModificationException 是一个运行时异常,当检测到对象在不允许的情况下被并发修改时抛出。 "并发修改" 的关键在于 不允许。 它并不一定需要真正的多线程并发,单线程中的某些迭代器操作也可能触发CME。

最常见的场景是:当使用迭代器(Iterator)遍历集合时,在迭代过程中,集合本身通过 add(), remove(), clear() 等方法修改了结构(即元素的增删),导致迭代器状态与集合状态不一致,从而抛出CME。

2. 单线程下的CME:Iterator的快速失败机制

让我们从单线程场景开始,理解CME的内在机制。 Java集合类(例如 ArrayList, LinkedList, HashSet, HashMap等)的迭代器通常实现了 fail-fast (快速失败) 机制。

  • 快速失败:这意味着迭代器会尽可能早地检测到结构修改,并在检测到时立即抛出 ConcurrentModificationException,而不是冒着在未来的某个不确定时刻抛出任意异常或产生不确定行为的风险。

  • modCount 字段:Java集合类(尤其是 AbstractList及其子类)通常维护一个 modCount 字段,用于记录集合结构被修改的次数。每次调用 add(), remove(), clear() 等修改集合结构的方法时,modCount 的值都会增加。

  • 迭代器内部的 expectedModCount 字段:当通过 iterator() 方法获取一个迭代器时,迭代器会初始化一个 expectedModCount 字段,其值等于集合当前的 modCount 值。

  • 迭代器操作时的检查:在每次调用迭代器的 next(), hasNext(), remove() 等方法时,迭代器会检查 expectedModCount 是否等于集合的 modCount。 如果不相等,说明集合在迭代器创建之后被修改过,迭代器会立即抛出 ConcurrentModificationException

示例代码:单线程CME

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

public class SingleThreadCME {

    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("A");
        list.add("B");
        list.add("C");

        Iterator<String> iterator = list.iterator();
        while (iterator.hasNext()) {
            String element = iterator.next();
            if (element.equals("B")) {
                list.remove("B"); // 直接修改集合结构
            }
        }
    }
}

运行这段代码会抛出 ConcurrentModificationException。 原因是在迭代过程中,我们直接使用 list.remove("B") 修改了集合的结构,导致迭代器的 expectedModCountlistmodCount 不一致。

3. 多线程下的CME:真正的并发修改

在多线程环境下,多个线程可能同时访问和修改同一个集合。 如果没有适当的同步措施,多个线程可能同时修改集合的结构,导致迭代器在遍历时检测到 modCount 的变化,从而抛出 ConcurrentModificationException

示例代码:多线程CME

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

public class MultiThreadCME {

    public static void main(String[] args) throws InterruptedException {
        List<String> list = new ArrayList<>();
        list.add("A");
        list.add("B");
        list.add("C");

        // 线程1:遍历并尝试删除元素
        Thread thread1 = new Thread(() -> {
            try {
                Thread.sleep(10); // 模拟一些耗时操作
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            Iterator<String> iterator = list.iterator();
            while (iterator.hasNext()) {
                String element = iterator.next();
                if (element.equals("B")) {
                    iterator.remove(); // 使用迭代器删除元素
                }
            }
        });

        // 线程2:尝试添加元素
        Thread thread2 = new Thread(() -> {
            list.add("D");
        });

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println(list);
    }
}

运行这段代码,即使使用了 iterator.remove(),仍然有可能抛出 ConcurrentModificationException。 这是因为线程2可能在线程1调用 iterator.next() 之后、调用 iterator.remove() 之前,先执行了 list.add("D")。 线程2对list进行的修改,使得线程1的迭代器 expectedModCountlistmodCount 不一致。

4. 诊断CME:堆栈信息与代码分析

当遇到 ConcurrentModificationException 时,首先要仔细分析堆栈信息,确定异常发生的具体位置。 堆栈信息会指出哪个方法抛出了异常,以及调用链。

  • 迭代器方法:如果异常发生在 Iterator.next(), Iterator.hasNext(), Iterator.remove() 等方法中,则很可能是迭代过程中集合结构被修改。

  • 集合修改方法:检查代码中是否有在迭代过程中直接调用集合的 add(), remove(), clear() 等方法。

  • 多线程环境:如果是在多线程环境下,需要仔细检查是否存在多个线程同时访问和修改同一个集合,并且没有进行适当的同步控制。

5. 解决CME的方案

针对不同场景下的 ConcurrentModificationException,有多种解决方案。

5.1 单线程解决方案

  • 使用迭代器自身的 remove() 方法:这是最推荐的单线程解决方案。 在迭代过程中需要删除元素时,不要直接调用集合的 remove() 方法,而是调用迭代器自身的 remove() 方法。 迭代器的 remove() 方法会在删除元素后,同步更新迭代器的 expectedModCount 值,使其与集合的 modCount 值保持一致。

    List<String> list = new ArrayList<>();
    list.add("A");
    list.add("B");
    list.add("C");
    
    Iterator<String> iterator = list.iterator();
    while (iterator.hasNext()) {
        String element = iterator.next();
        if (element.equals("B")) {
            iterator.remove(); // 使用迭代器删除元素
        }
    }
  • 使用 ListIterator:对于 List 接口的实现类(例如 ArrayList, LinkedList),可以使用 ListIterator 迭代器。 ListIterator 除了具有 Iterator 的功能外,还提供了 add()set() 方法,可以在迭代过程中安全地添加和修改元素,而不会抛出 ConcurrentModificationException

    List<String> list = new ArrayList<>();
    list.add("A");
    list.add("B");
    list.add("C");
    
    ListIterator<String> iterator = list.listIterator();
    while (iterator.hasNext()) {
        String element = iterator.next();
        if (element.equals("B")) {
            iterator.remove();
        }
    }
  • 先收集需要删除的元素,再批量删除:如果需要在迭代过程中删除多个元素,可以先将需要删除的元素收集到一个临时集合中,然后在迭代结束后,使用 removeAll() 方法一次性删除这些元素。

    List<String> list = new ArrayList<>();
    list.add("A");
    list.add("B");
    list.add("C");
    list.add("B");
    
    List<String> toRemove = new ArrayList<>();
    for (String element : list) {
        if (element.equals("B")) {
            toRemove.add(element);
        }
    }
    list.removeAll(toRemove);
  • 使用Java 8的Stream API:使用Stream API可以更简洁地实现集合的过滤和转换操作,避免直接操作集合的结构。

    List<String> list = new ArrayList<>();
    list.add("A");
    list.add("B");
    list.add("C");
    list.add("B");
    
    list = list.stream()
               .filter(element -> !element.equals("B"))
               .toList(); // 或者 collect(Collectors.toList());

5.2 多线程解决方案

  • 使用线程安全的集合类:Java提供了多种线程安全的集合类,例如 ConcurrentHashMap, CopyOnWriteArrayList, ConcurrentLinkedQueue 等。 这些集合类在设计时考虑了并发访问的场景,提供了内部的同步机制,可以避免 ConcurrentModificationException

    import java.util.concurrent.CopyOnWriteArrayList;
    import java.util.List;
    
    public class ThreadSafeList {
        public static void main(String[] args) throws InterruptedException {
            List<String> list = new CopyOnWriteArrayList<>();
            list.add("A");
            list.add("B");
            list.add("C");
    
            Thread thread1 = new Thread(() -> {
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                Iterator<String> iterator = list.iterator();
                while (iterator.hasNext()) {
                    String element = iterator.next();
                    if (element.equals("B")) {
                        iterator.remove();
                    }
                }
            });
    
            Thread thread2 = new Thread(() -> {
                list.add("D");
            });
    
            thread1.start();
            thread2.start();
    
            thread1.join();
            thread2.join();
    
            System.out.println(list);
        }
    }

    CopyOnWriteArrayList 的实现原理是,每次修改集合时,都会创建一个新的副本,并在新的副本上进行修改。 这样,迭代器始终遍历的是原始的副本,而不会受到其他线程修改的影响。 但是,CopyOnWriteArrayList 的缺点是,每次修改都会创建新的副本,开销较大,不适合频繁修改的场景。

    ConcurrentHashMap 使用分段锁技术,将整个 Map 分成多个段,每个段都有自己的锁。 这样,多个线程可以同时访问不同的段,从而提高并发性能。

  • 使用同步代码块或锁:可以使用 synchronized 关键字或 Lock 接口对集合的访问和修改进行同步。 确保在迭代和修改集合时,只有一个线程可以访问集合。

    import java.util.ArrayList;
    import java.util.List;
    import java.util.Iterator;
    
    public class SynchronizedList {
        public static void main(String[] args) throws InterruptedException {
            List<String> list = new ArrayList<>();
            list.add("A");
            list.add("B");
            list.add("C");
    
            Object lock = new Object(); // 用于同步的锁对象
    
            Thread thread1 = new Thread(() -> {
                synchronized (lock) {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    Iterator<String> iterator = list.iterator();
                    while (iterator.hasNext()) {
                        String element = iterator.next();
                        if (element.equals("B")) {
                            iterator.remove();
                        }
                    }
                }
            });
    
            Thread thread2 = new Thread(() -> {
                synchronized (lock) {
                    list.add("D");
                }
            });
    
            thread1.start();
            thread2.start();
    
            thread1.join();
            thread2.join();
    
            System.out.println(list);
        }
    }

    使用 synchronized 关键字或 Lock 接口可以确保线程安全,但是会降低并发性能。 因此,需要根据实际情况选择合适的同步策略。

  • 使用 Collections.synchronizedList():可以使用 Collections.synchronizedList() 方法将一个普通的 List 转换为线程安全的 ListCollections.synchronizedList() 方法会在每个方法上添加同步锁,从而实现线程安全。

    import java.util.ArrayList;
    import java.util.Collections;
    import java.util.List;
    
    public class CollectionsSynchronizedList {
        public static void main(String[] args) throws InterruptedException {
            List<String> list = Collections.synchronizedList(new ArrayList<>());
            list.add("A");
            list.add("B");
            list.add("C");
    
            Thread thread1 = new Thread(() -> {
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (list) { // 必须同步 list 本身
                    Iterator<String> iterator = list.iterator();
                    while (iterator.hasNext()) {
                        String element = iterator.next();
                        if (element.equals("B")) {
                            iterator.remove();
                        }
                    }
                }
            });
    
            Thread thread2 = new Thread(() -> {
                synchronized (list) { // 必须同步 list 本身
                    list.add("D");
                }
            });
    
            thread1.start();
            thread2.start();
    
            thread1.join();
            thread2.join();
    
            System.out.println(list);
        }
    }

    注意:即使使用了 Collections.synchronizedList(),仍然需要在迭代时手动进行同步,因为 Collections.synchronizedList() 只是保证了每个方法调用的原子性,而不能保证迭代过程的原子性。 也就是说,在迭代过程中,其他线程仍然可以修改集合,导致 ConcurrentModificationException

5.3 总结表格

场景 问题 解决方案 优点 缺点
单线程 迭代过程中直接修改集合结构 1. 使用迭代器自身的 remove() 方法。 2. 使用 ListIteratoradd()set() 方法。 3. 先收集需要删除的元素,再批量删除。 4. 使用 Java 8 的 Stream API。 1. 安全可靠,避免 ConcurrentModificationException。 2. ListIterator 提供了更多功能。 3. 批量删除性能更好。 4. Stream API 代码更简洁。 1. 需要修改代码。 2. ListIterator 只能用于 List 接口的实现类。 3. 批量删除需要额外的空间。 4. Stream API 可能有性能损耗。
多线程 多个线程同时修改集合结构,未进行同步 1. 使用线程安全的集合类(例如 ConcurrentHashMap, CopyOnWriteArrayList)。 2. 使用 synchronized 关键字或 Lock 接口进行同步。 3. 使用 Collections.synchronizedList() 1. 线程安全,避免 ConcurrentModificationException。 2. 可以灵活控制同步范围。 3. 简单易用。 1. 线程安全集合类可能性能较低。 2. 同步可能导致性能下降。 3. 需要手动同步迭代过程。

6. 避免CME的最佳实践

  • 尽量避免在迭代过程中修改集合结构:这是避免 ConcurrentModificationException 的最根本方法。 如果确实需要在迭代过程中修改集合结构,可以考虑使用迭代器自身的 remove() 方法或 ListIteratoradd()set() 方法。
  • 在多线程环境下,使用线程安全的集合类或进行适当的同步控制:确保多个线程在访问和修改集合时,不会发生冲突。
  • 仔细分析堆栈信息,确定异常发生的具体位置:有助于快速定位问题,并选择合适的解决方案。
  • 编写单元测试,覆盖各种场景:有助于及早发现潜在的 ConcurrentModificationException 风险。

7. 总结

ConcurrentModificationException 是一个常见的 Java 集合异常,它发生在迭代器遍历集合时,集合结构被意外修改的情况。理解单线程和多线程环境下 CME 的不同原因以及对应的解决方案,对于编写健壮的 Java 代码至关重要。 在单线程环境中,应该使用迭代器提供的 remove() 方法或Stream API来修改集合,而在多线程环境中,需要使用线程安全的集合类或进行适当的同步控制。 始终记住,及早发现和预防 CME 比在生产环境中调试它要容易得多。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注