Java 中的并发修改异常

2024年10月23日 | 11 分钟阅读

Java 中的 ConcurrentModificationException 是一个异常,它告诉我们,当集合在迭代其任何元素时被结构性地修改了。这通常发生在迭代器(例如 Iterator、ListIterator)遍历集合时,集合被修改(例如添加或删除元素)。

让我们更详细地探讨一下

1. 并发和迭代

在 Java 中,集合可能会被并发修改,即一个线程正在修改集合,而另一个线程正在遍历它。然而,Java 集合框架默认并非线程安全。这意味着,如果您尝试在另一个线程正在迭代集合时修改它,您将遇到 ConcurrentModificationException 这样的问题。

2. ConcurrentModificationException 的原因

ConcurrentModificationException 最常见的情况是,当您使用迭代器(例如 Iterator、ListIterator)遍历集合,同时又在迭代器之外修改集合(即添加/删除元素)时。迭代器负责跟踪集合的内部结构。如果它检测到集合的结构在它之外被更改了,它就会抛出 ConcurrentModificationException,这意味着当前的迭代是无效的。

ConcurrentModificationException 的示例

注意:此异常并非仅在某个其他线程尝试修改 Collection 对象时才会抛出。如果单个线程调用了一些违反对象约定的方法,也可能发生这种情况。当一个线程在被某个“快速失败”迭代器迭代 Collection 对象时尝试修改它,迭代器就会抛出异常。

示例

输出

ConcurrentModificationException in Java

这条消息表明,当迭代器在遍历列表时调用 next() 方法,而我们同时对其进行修改时,就会抛出异常。但如果我们像下面这样修改 HashMap,就不会抛出任何异常,因为 HashMap 的大小不会改变。

例如:

输出

Map Value:1
Map Value:2
Map Value:3

这个例子完全正常,因为在迭代器遍历 map 时,map 的大小并没有改变。只有在 if 语句中更新了 map。

ConcurrentModificationException 的构造函数

ConcurrentModificationException 有 4 种构造函数:

  1. public ConcurrentModificationException()-
    此构造函数创建一个没有参数的 ConcurrentModificationException。
  2. public ConcurrentModificationException(String message)
    此构造函数创建一个带有详细消息的 ConcurrentModificationException,用于指定异常。
  3. public ConcurrentModificationException(Throwable cause)
    此构造函数创建一个带有原因和消息的 ConcurrentModificationException(cause==null?null:cause.toString())。原因稍后可通过 Throwable.getCause() 获取。
  4. public ConcurrentModificationException(String message, Throwable cause)
    此构造函数创建一个带有详细消息和原因的 ConcurrentModificationException(cause==null?null:cause.toString())。消息稍后可通过 Throwable.getMessage() 获取,原因稍后可通过 Throwable.getCause() 获取。

如何在多线程环境中避免 ConcurrentModificationException?

为了在多线程环境中避免 ConcurrentModificationException,我们需要采用技术来同步对这些共享集合的访问,或者使用线程安全的数据结构。以下是一些实现此目的的技术:

1. 使用线程安全集合

Java 在 java.util.concurrent 包中提供了许多线程安全集合类,如 ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue 等,它们被设计为在多线程环境中安全使用,无需外部同步。

示例

2. 同步访问

如果您使用的是常规集合(例如 ArrayList、HashMap 等),则必须使用显式阻塞/同步(例如使用 synchronized 关键字或 ReadWriteLock)手动同步它们的访问。

示例

3. 迭代器的 remove() 方法

在遍历集合时,请使用迭代器的 remove() 方法,而不是直接修改集合。这可以确保所有修改都通过迭代器安全地进行。

示例

4. 复制集合

如果集合的大小很小,并且对其进行迭代的频率不高,那么您应该考虑在修改它之前创建一个副本。这样,您可以安全地重构副本,而无需检查并发修改。

5. 使用不可变集合

如果集合在创建后不需要修改,请考虑使用 Google Guava 库或 Java 9+ 的 List.of()、Set.of() 等提供的不可变集合。不可变集合可以在线程之间安全共享,无需同步。

示例

6. 原子操作

对于特殊情况,您可以使用原子操作,这些操作由 AtomicInteger、AtomicLong 等类提供,以实现线程安全的修改。

示例

7. 正确的线程同步

正确的线程同步可以避免并发修改问题。可以使用 synchronized blocks、Lock 对象等同步构造来控制对共享数据的访问。

ConcurrentModificationExample.java

输出

Added: Element 0
Added: Element 1
Added: Element 2
Added: Element 3
Added: Element 4

4.2 如何在单线程环境中避免 ConcurrentModificationException?

在单线程环境中,您通常不会遇到与多线程环境相同的并发问题。但是,如果您尝试在迭代集合时修改它,您仍然可能遇到 ConcurrentModificationException。以下是在单线程环境中避免此问题的方法:

1. 使用迭代器的 remove() 方法

2. 使用 for-each 循环

在 Java 5 及更高版本中,您可以使用增强 for 循环,它在内部使用迭代器,并允许安全地删除元素。

3. 使用 ListIterator

如果您需要双向遍历列表并对其进行修改,可以使用 ListIterator。

4. 复制集合

如果您需要在迭代时修改集合,请考虑创建集合的副本,然后迭代副本。这样,修改就不会影响原始集合。

5. 使用 Stream API

从 Java 8 及更高版本开始,您可以使用 Stream API 安全地操作集合。

AvoidConcurrentModification.java

输出

After removing 'B' with Iterator: [A, C]
After removing 'C' with for-each loop: [A]
After removing 'A' with ListIterator: []
After removing 'B' by copying: []
After removing 'A' with Stream API: []

5. 调试 ConcurrentModificationException

发生此类问题时,您需要分析代码,找出代码的哪个部分在迭代集合的同时并发修改了它。一旦找到问题,您就可以选择上述策略之一来解决它。

ConcurrentModificationExample.java

输出

Processing element: A
Processing element: B
Modifying the list while iterating...
Modified list: [A, C]
Processing element: C

ConcurrentModificationException 的优点

检测并发修改: ConcurrentModificationException 有助于识别在迭代期间集合被同时修改的场景。它充当早期预警系统。因此,可以识别服务过载的风险。

维护集合的完整性: 在检测到冲突修改时抛出异常,可以确保集合保持一致。这消除了在同时修改和迭代期间可能出现的小错误和数据完整性问题。

防止未定义行为: 同时,在迭代过程中处理同一个集合时,可能会出现不可预测的行为和数据不一致。ConcurrentModificationException 通过顺序访问或同步它们来避免这些情况。

鼓励安全的迭代实践: 遇到 ConcurrentModificationException 会促使程序员应用安全的迭代技术,例如正确处理迭代器、使用线程安全集合或在多线程环境中同步对集合的并发访问。

便于调试: 当抛出 ConcurrentModificationException 时,它会提供关于并发修改发生的代码位置的重要信息。这反过来又便于调试和解决与同时访问相关的问题。

推广最佳实践: ConcurrentModificationException 提醒并发编程的开发人员保持良好的实践,包括使用同步机制、采用线程安全的数据结构以及理解并发访问的后果。

增强代码可读性:当在迭代期间检测到并发修改时,明确抛出 ConcurrentModificationException 可以使代码更具可读性和自解释性。它向开发人员明确表明,在并发修改集合时正在进行迭代。这种清晰性有助于理解代码库,并降低意外引入并发修改相关错误的风险。

促进纠正措施: 当引发 ConcurrentModificationException 时,它会促使开发人员审查并可能重构其代码,以更有效地处理并发修改。这可能涉及重新设计数据结构、实现适当的同步机制或重新思考并发模型。通过这种方式,异常充当了提高多线程环境中代码的健壮性和可靠性的催化剂,从而带来更好的长期可维护性和性能。

ConcurrentModificationException 的缺点

运行时异常: ConcurrentModificationException 是一个运行时异常,因此在编译期间无法检测到,如果未处理,可能会导致生产环境中意外崩溃。这种特性带来了重大风险,因为与并发修改相关的问题可能会意外出现,而开发过程中没有任何预先的指示。如果没有主动处理,这些运行时错误会破坏应用程序的稳定性和用户体验,这凸显了实施健壮的机制来提前检测和管理并发修改的重要性,从而最大程度地减少破坏性运行时异常的可能性。

信息有限: ConcurrentModificationException 异常的消息可能并不总是提供足够详细的信息来准确识别并发修改的根本原因。开发人员有时可能需要使用调试技术来查明确切的问题。

性能开销: 当抛出 ConcurrentModificationException 时,会伴随堆栈跟踪生成和异常处理过程的相当大的开销。如果并发修改频繁发生,这会严重影响应用程序性能。

潜在的静默失败: 在某些情况下,可能不会触发 ConcurrentModificationException,导致静默失败,即并发修改未被检测到。这种情况带来了严重的风险,因为未被注意到的并发修改可能导致细微的错误和数据损坏。这种静默失败很难识别和纠正,可能会对数据完整性和应用程序可靠性造成长期损害。如果没有异常提供的明确指示,这些隐藏的问题可能会持续存在,从而加剧故障排除和纠正的难度,最终损害系统的稳定性和功能。

在多线程中的实用性有限: ConcurrentModificationException 可以检测单线程环境中的并发修改,但在多线程环境中效果较差,因为它假设多个线程之间不会发生并发修改。这种使用情况需要更高水平的同步。

潜在的不可预测行为: 在某些情况下,遇到 ConcurrentModificationException 可能会导致应用程序行为不可预测。根据操作的时序和顺序,异常可能被不一致地抛出或根本不抛出。这种不可预测性使得开发人员难以可靠地重现和诊断与并发修改相关的问题。

处理并发修改的难度: 虽然 ConcurrentModificationException 表明发生了并发修改,但它本身并不提供处理或解决该问题的解决方案。开发人员仍需自己实现管理并发访问集合的机制,这可能很复杂且容易出错。缺乏内置的并发控制支持可能会导致次优解决方案,或者需要大量工作来实现健壮的并发管理策略。


下一个主题Java 教程