一般来说,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 种子类型集合: ListSetQueue,再下面是一些抽象类,最后是具体实现类,常用的有 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,目前访问受限

Last modification:April 21st, 2021 at 05:24 pm
喵ฅฅ