一般来说,java.util.ConcurrentModificationException
并发修改异常是出现在高并发同时读写的线程不安全的集合中。例如:ArrayList、HashMap之类的。
这时候一般只需要替换成并发安全的ConcurrentHashMap
就完事大吉了。但是ConcurrentHashMap的弱一致性在高并发场景下仍可能出现问题。
这里也可能不是这个问题因为替换成HashTable仍是出现了该报错,目前就先按照这个做记录。
发现问题是在取ConcurrentHashMap里面嵌套的Collection遍历导致的,改成CopyOnWriteArrayList
解决。
场景复现
数据保存在ETCD上,项目初始化加载的时候会加载到本地的ConcurrentHashMap中,并且动态监听修改写入到本地Map。
在添加数据的时候,先把该条数据的内容与本地Map中进行匹配,如果内容相同则禁止重复添加。
该异常是出现在isSensitiveWordDuplicated()
的流处理方法中。
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遍历的时候出错了,代码如下:
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()方法简单分析一下。
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()方法,看下这个方法:
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)
这篇关于临界区与业务中代码一致性问题的探讨讲的不错:从ConcurrentHashMap谈谈一致性
ConcurrentHashMap 与 CopyOnWriteHashMap
类 | 一致性 | 效率 | 内存占用 | 适用情况 |
---|---|---|---|---|
ConcurrentHashMap | 弱一致性 | 较高 | 一般 | 无要求 |
CopyOnWriteHashMap | 最终一致性 | 高 | 双倍内存 | 读多写少 |
一些参考来自Hosee,目前访问受限