使用Maps键集与流时出现ConcurrentModificationException异常

28

我想从 someMap 中移除那些键不在 someList 中的所有项。
看一下我的代码:

someMap.keySet()
    .stream()
    .filter(v -> !someList.contains(v))
    .forEach(someMap::remove);

我收到了 java.util.ConcurrentModificationException 异常。
既然流不是并行的,为什么会出现这个异常?
最优雅的解决方法是什么?

4个回答

46

@Eran已经解释了如何更好地解决这个问题。我将解释为什么会出现ConcurrentModificationException

ConcurrentModificationException是由于您正在修改流源造成的。您的Map可能是HashMapTreeMap或其他非并发映射。假设它是一个HashMap。每个流都由Spliterator支持。如果spliterator没有IMMUTABLECONCURRENT特征,那么根据文档所说:

绑定Spliterator后,如果检测到结构干扰,则Spliterator应尽最大努力抛出ConcurrentModificationException。这样做的Spliterator被称为fail-fast

因此,HashMap.keySet().spliterator()不是IMMUTABLE(因为这个Set可以被修改),也不是CONCURRENT(对于HashMap来说并发更新是不安全的)。因此,它只是检测到并发更改并按照spliterator文档的规定抛出一个ConcurrentModificationException
此外,值得引用的是HashMap文档:
这个类的所有“集合视图方法”返回的迭代器都是“快速失败”的:如果在迭代器创建后的任何时候,除了通过迭代器自己的remove方法以外的任何方式对Map进行结构性修改,迭代器都会抛出ConcurrentModificationException异常。因此,在面对并发修改时,迭代器会迅速而干净地失败,而不是冒着在未来某个不确定的时间出现任意、不确定行为的风险。

请注意,迭代器的快速失败行为不能得到保证,因为一般来说,在未同步的并发修改存在的情况下,不可能做出任何硬性保证。快速失败迭代器尽最大努力抛出ConcurrentModificationException异常。因此,编写依赖于此异常的程序来保证其正确性是错误的:迭代器的快速失败行为只应用于检测bug。

虽然它只提到了迭代器,但我认为Spliterator也是一样的。


1
我认为这是最好的答案,但你可以编辑它以添加@Eran提到的解决方案。这将对未来遇到相同问题的任何人都是100%满意的。 - Mariusz Jaskółka
3
@MariuszJaskółka,Eran的答案也在这里,其他人也可能会看到它。 它是正确的,我为此投了赞成票。我可以添加一个参考他的解决方案。 - Tagir Valeev

17

你不需要使用Stream API。可以在keySet上使用retainAll方法。对keySet()返回的Set进行的任何更改都会反映在原始的Map中。

someMap.keySet().retainAll(someList);

1
好的,这是我第二个问题的好答案。但我仍然不知道为什么会出现java.util.ConcurrentModificationException - Mariusz Jaskółka

12

你的流调用(逻辑上)与以下代码执行的操作相同:

for (K k : someMap.keySet()) {
    if (!someList.contains(k)) {
        someMap.remove(k);
    }
}

如果运行此代码,你将会发现它会抛出 ConcurrentModificationException 异常,因为它在你遍历 map 的同时修改了它。如果你查看 文档,你会注意到以下内容:

请注意,此异常并不总是表示对象已被不同的线程并发修改。如果单个线程发出一系列违反对象合同的方法调用序列,则该对象可能会抛出此异常。例如,如果一个线程直接修改集合,而另一个使用 fail-fast 迭代器遍历该集合,则迭代器将抛出此异常。

这就是你正在做的事情,你正在使用显然具有 fail-fast 迭代器的 map 实现,因此会抛出此异常。

一种可能的替代方法是直接使用迭代器删除项目:

for (Iterator<K> ks = someMap.keySet().iterator(); ks.hasNext(); ) {
    K next = ks.next();
    if (!someList.contains(k)) {
        ks.remove();
    }
}

10
稍后回答,但你可以将收集器插入到管道中,使得forEach操作的是一个Set,该Set持有键的副本:
someMap.keySet()
    .stream()
    .filter(v -> !someList.contains(v))
    .collect(Collectors.toSet())
    .forEach(someMap::remove);

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接