Loading... 一般来说,`java.util.ConcurrentModificationException`并发修改异常是出现在**高并发同时读写的线程不安全的集合**中。例如:ArrayList、HashMap之类的。 <!--more--> 这时候一般只需要替换成并发安全的 `ConcurrentHashMap`就完事大吉了。但是ConcurrentHashMap的**弱一致性**在高并发场景下仍可能出现问题。 这里也可能不是这个问题因为替换成HashTable仍是出现了该报错,目前就先按照这个做记录。 --- 发现问题是在取ConcurrentHashMap里面嵌套的Collection遍历导致的,改成 `CopyOnWriteArrayList`解决。 ## 场景复现 > 数据保存在ETCD上,项目初始化加载的时候会加载到本地的ConcurrentHashMap中,并且动态监听修改写入到本地Map。 > 在添加数据的时候,先把该条数据的内容与本地Map中进行匹配,如果内容相同则禁止重复添加。 > 该异常是出现在 `isSensitiveWordDuplicated()`的流处理方法中。 ```java public void add(SensitiveWordEtcdFormParam sensitiveWordEtcdFormParam) { ... // 处理 Windows 平台输入的换行符 \r\n String[] split = word.split("\r\n"); ... for (String s : split) { // 从缓存的CurrentHashMap中读取,进行匹配 if (isSensitiveWordDuplicated(sensitiveWordEtcdFormParam.getType(), s)) { duplicateWords.add(s); continue; } SensitiveWordEtcdVo item = new SensitiveWordEtcdVo(); item.setWord(s); ... try { customizedClient.post(SENSITIVE_WORD_KEY.replace("${type}", sensitiveWordEtcdFormParam.getType().name().toLowerCase()), sensitiveWordEtcdVoJsonValue); } catch (Exception cause) { // 这里的打警告,异常不抛出,因为一个出错,不能影响其他正确的敏感词被添加 } } if (needTip && CollectionUtils.isNotEmpty(duplicateWords)) { // 重复警告 } } ``` 因为走etcd监听存取是用Map(旧)、CurrentHashMap(新)来接的,两边都出现这个bug还以为是CurrentHashMap的弱一致性导致的。 没想到是Map里面还套了一层 `Collection`,当Stream遍历的时候出错了,代码如下: ```java private Boolean isSensitiveWordDuplicated(SensitiveWordEnum sensitiveWordEnum, String word) { boolean result = false; Map<SensitiveWordEnum, Collection<SensitiveWordEtcdVo>> sensitiveWordEnumCollectionMap = sensitiveWordConf.get(); // 到这步之前其实都算是线程安全的,这里提取出Collection所以并发下出错了 Collection<SensitiveWordEtcdVo> sensitiveWords = sensitiveWordEnumCollectionMap.get(sensitiveWordEnum); if (CollectionUtils.isNotEmpty(sensitiveWords)) { result = sensitiveWords.stream().anyMatch(item -> word.equals(item.getWord())); } return result; } ``` ## 解决方案 将此 `Collection`从上游全部替换为 `CopyOnWriteArrayList`,解决。 ## 原因分析 Collection 接口有 3 种子类型集合: `List`、`Set` 和 `Queue`,再下面是一些抽象类,最后是具体实现类,常用的有 ArrayList、LinkedList、HashSet、LinkedHashSet、ArrayBlockingQueue等。这里就拿ArrayList的父类 `AbstractList`中iterator()方法简单分析一下。 ```java private class Itr implements Iterator<E> { int cursor; // 下一元素索引 int lastRet = -1; // 上一元素索引; -1 if no such int expectedModCount = modCount; // ArrayList修改次数的期望值;modCount是每次add/remove前+1,执行后-1 Itr() {} public boolean hasNext() { // size为数组ArrayList的大小 return cursor != size; } @SuppressWarnings("unchecked") public E next() { // 第一次初始化时expectedModCount和modCount相等 checkForComodification(); int i = cursor; if (i >= size) throw new NoSuchElementException(); Object[] elementData = ArrayList.this.elementData; // elementData:存储ArrayList的元素的数组缓冲区。 if (i >= elementData.length) throw new ConcurrentModificationException(); cursor = i + 1; return (E) elementData[lastRet = i]; } public void remove() { // 增加在ListItr里,这里不需要分析那么多 if (lastRet < 0) throw new IllegalStateException(); checkForComodification(); try { ArrayList.this.remove(lastRet); //modCount++ cursor = lastRet; lastRet = -1; // 如果在这步之前,另一个线程在next()读取集合,就会走checkForComodification();方法然后报错 expectedModCount = modCount; } catch (IndexOutOfBoundsException ex) { throw new ConcurrentModificationException(); } } @Override @SuppressWarnings("unchecked") public void forEachRemaining(Consumer<? super E> consumer) { ... } final void checkForComodification() { // 如果修改的次数值和期望的不一致,则抛出异常 if (modCount != expectedModCount) throw new ConcurrentModificationException(); } } ``` 在它的子类中还实现了 `remove(Object o)`的方法,调用了 `fastRemove(Object o)`,走的流程也是一样的根据下标,下一次还是走 `Iterator.next()` ## 关于ConcurrentHashMap的弱一致性 ConcurrentHashMap中的迭代器主要包括entrySet、keySet、values方法。它们大同小异,这里选择entrySet解释。当我们调用entrySet返回值的iterator方法时,返回的是EntryIterator,在EntryIterator上调用next方法时,最终实际调用到了HashIterator.advance()方法,看下这个方法: ```java final void advance() { if (nextEntry != null && (nextEntry = nextEntry.next) != null) return; while (nextTableIndex >= 0) { if ( (nextEntry = currentTable[nextTableIndex--]) != null) return; } while (nextSegmentIndex >= 0) { Segment<K,V> seg = segments[nextSegmentIndex--]; if (seg.count != 0) { currentTable = seg.table; for (int j = currentTable.length - 1; j >= 0; --j) { if ( (nextEntry = currentTable[j]) != null) { nextTableIndex = j - 1; return; } } } } } ``` 这个方法在遍历底层数组。在遍历过程中,如果已经遍历的数组上的内容变化了,迭代器不会抛出ConcurrentModificationException异常。如果未遍历的数组上的内容发生了变化,则有可能反映到迭代过程中。这就是ConcurrentHashMap迭代器弱一致的表现。 **ConcurrentHashMap的弱一致性主要是为了提升效率,是一致性与效率之间的一种权衡。要成为强一致性,就得到处使用锁,甚至是全局锁,这就与Hashtable和同步的HashMap一样了。** 源码解析的话以后有机会再摸吧[ConcurrentHashMap源码解析(jdk1.8)](https://blog.csdn.net/programmer_at/article/details/79715177) 这篇关于临界区与业务中代码一致性问题的探讨讲的不错:[从ConcurrentHashMap谈谈一致性](https://www.jianshu.com/p/0c12cc36e174) ### ConcurrentHashMap 与 CopyOnWriteHashMap | 类 | 一致性 | 效率 | 内存占用 | 适用情况 | | ------------------ | ---------- | ---- | -------- | -------- | | ConcurrentHashMap | 弱一致性 | 较高 | 一般 | 无要求 | | CopyOnWriteHashMap | 最终一致性 | 高 | 双倍内存 | 读多写少 | --- 一些参考来自[Hosee](https://my.oschina.net/hosee),目前访问受限 Last modification:August 22, 2022 © Allow specification reprint Like 0 喵ฅฅ