JDK-针对集合迭代的探讨

乐云一
  • 笔记
  • note
About 1379 wordsAbout 5 min

背景

见以下代码:

public static List<Integer> data = new ArrayList<>();

static{
    data.add(1);
    data.add(2);
    data.add(3);
    data.add(4);
}

public static void main(String[] args) {
    errorIteration();
}

/**
 * 错误的遍历
 */
public static void errorIteration(){
    for (Integer datum : data) {
        data.remove(2);
    }
}

Exception in thread "main" java.util.ConcurrentModificationException

相信看到上述代码的人脑海中蹦出来的一定是 ConcurrentModificationException 的异常错误,作为一个集合操作中最基础的异常,它是JDK设计之初对基础集合类线程访问安全的一个写死的规则,即:禁止两个线程对同一个集合进行遍历和[增加、删除、修改]的操作

这也是经常可以听到的,集合中的 Fail-Fast 与 Fail-Safe 机制,本篇先简单的总结一下这两个机制的原理,而后结合场景推荐几种遍历集合的安全逻辑。

Fail-Fast

简译:快速失败

覆盖于 java.util包下的所有集合类

在集合进行增加、删除、修改操作时,会进行 modCount 变量的校验;在集合进行遍历时,会生成本次集合操作的 expectedModCount 变量,那么在线程对改集合进行数据操作时,则会修改其中的 expectedModCount变量,当 modCount != expectedModCount 触发 Fail-Fast

在源码中关于这方面逻辑的一小片段:

        public void remove() {
            if (lastRet < 0)
                throw new IllegalStateException();
            checkForComodification();
            try {
                ArrayList.this.remove(lastRet);
                cursor = lastRet;
                lastRet = -1;
                expectedModCount = modCount;
            } catch (IndexOutOfBoundsException ex) {
                throw new ConcurrentModificationException();
            }
        }

        final void checkForComodification() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
        }

Fail-Safe

简译:安全失败

Fail-Safe于Fail-Fast相反,取消了对 modCount 变量的校验,采用CopyToWrite的方式,将需要操作的集合拷贝出一份然后再操作,这样原集合和遍历的集合不会是同一份。

隔离出了一个在操作上安全的区域,但是在数据上却是错误的数据。因为遍历的集合,和操作的集合不是同一份,遍历期间的数据和操作后的数据是分割开的,因此这也只是多线程操作集合的一种相对安全的机制。

java.util.concurrent包下的容器都是安全失败

几种安全的手法

原始数据:

    public static List<Integer> data = new ArrayList<>();

    static{
        data.add(1);
        data.add(2);
        data.add(3);
        data.add(4);
    }

1、

第一种是很多人最先接触的一种,即遍历时需要做删除操作:

    public static void iteratorMethod(){
        Iterator<Integer> iterator = data.iterator();
        while(iterator.hasNext()){
            Integer next = iterator.next();
            if(next.equals(1)) iterator.remove();
        }
    }

常出现在只是遍历然后删除指定对象的业务中,但是不足很明显,无法做新增操作。

由于迭代器拿到的数据对象是当时的,因此新增后的数据不会影响到先得到的迭代器对象。

2、

第二种也是使用JDK提供的容器类:CopyOnWriteArrayList

作为JUC包下的产物,他和其他原子类对数据的操作类型,将操作数据和读取数据分割开来。

    public static void copyToWrite(){
        CopyOnWriteArrayList<Integer> cwa = new CopyOnWriteArrayList<>(data);
        for(int i =0 ;i<cwa.size();i++){
            Integer b = cwa.get(i);
            if(b == 2){
                cwa.remove(i);
            }
        }
    }

因此在遍历时做任何操作都会时安全的,但是对于原数据的数据安全来说,是毁灭性的使用,使用需要酌情考虑

3、

第三种是我比较喜欢的,采用动态坐标的方式遍历集合:

将遍历数组的判断动态化,在遍历判断中,只需要在新增或删除数据时,对坐标建立新的容器大小关系。

这样做的好处是,适配 删除 新增 操作,且对原业务逻辑侵入较小

    public static void trendsIndex(){
        Integer size = data.size();
        Integer index = 0;
        while(index != size){
            Integer num = data.get(index++);
            if(num == 4){
                size+=1;
                data.add(5);
            }
            System.out.println(num);
        }
    }

4、

第四种,熟悉DFS算法的伙伴都知道,Queue 可以非常灵活的运用在DFS中的一个原因是,他的Peek和Poll方法可以自由的让队列中的数据先进先出化。

那么在我们安全遍历的手法中,恰好可以使用他polladd 方法的特殊性,做一个从头到尾,又随意时间添加的队列遍历

    public static void queueIterator(){
        Queue<Integer> queue = new LinkedList<>(data);
        while(!queue.isEmpty()){
            Integer num = queue.poll();
            if(num == 4){
                queue.add(5);
            }
            System.out.println(num);
        }
    }

5、

最后一种,使用类锁的形式对集合操作的线程数进行管控

可能很多人会觉得,遍历集合这么一件小事为什么需要设计一把锁,甚至是偏向锁去控制数据安全。

但其实加锁于遍历集合可以并不冲突,在经典的 for-each 循环案例中,锁影响性能是无可厚非的。

但是在 JDK 的 paralleStream 并行流操作中,这点性能消耗恰好可以被多线程式遍历优化。

    public static void parallelStreamIterator(){
        List<Integer> list = new ArrayList<>(data);
        Lock lock = new ReentrantLock();
        list.parallelStream().forEach(adata->{
            lock.lock();
            try {
                if(adata==4){
                    list.add(5);
                }
                System.out.println(adata);
            }finally {
                lock.unlock();
            }
        });
    }

以上代码仅作思路参考,因为会触发 ConcurrentModificationException异常

总结

在一次无意中使用迭代器遍历时,往原数组新增元素,但发现不影响本次迭代器迭代后有感。

虽然马上就反应过来使用了 Queue 的方式解决,但是犯了这种严重级BUG的错误,就该来好好记录 😦

Last update:
Contributors: LeYunone
Comments
  • Latest
  • Oldest
  • Hottest
Powered by Waline v2.14.7