JDK并发修改异常的一个“BUG“

很多电商公司早期的架构都是基于PHP,所以我身边会有很多很厉害的PHP老哥,但现在都在写Java。昨天看到他在看Java的并发修改异常,正打算秀一波操作,却被他的一个问题难住了:

复制代码
public class ForeachTest {
 
    public static void main(String[] args) {
 
        List<String> list = new ArrayList<>();
        list.add("a");
        list.add("b");
        list.add("c");
 
        for (String value : list) {
            if ("b".equals(value)) {
                list.remove(value);
            }
        }
 
        System.out.println(list);
    }
}

问:运行上面的代码会发生什么?

复习并发修改异常

先留个悬念,我们先来复习一下并发修改异常是怎么回事:

复制代码
public class ForeachTest {

    public static void main(String[] args) {

        List<Integer> list = new ArrayList<>();
        list.add(1);
        list.add(2);
        list.add(3);

        // 普通for
        plainForMethod(list);
        // 增强for,底层是迭代器
        foreachMethod(list);
    }

    private static void plainForMethod(List<Integer> list) {
        for (int i = 0; i < list.size(); i++) {
            System.out.println(list.get(i));
        }
    }

    private static void foreachMethod(List<Integer> list) {
        for (Integer integer : list) {
            System.out.println(integer);
        }
    }
}

直接用IDEA打开源码:

你会发现增强for的底层就是Iterator,而Iterator的next()方法会检查并发修改异常,简而言之就是集合的"版本号"是否在遍历过程中发生了改变:

那么什么时候"版本号"modCount会改变呢?增删都会改变:

了解了并发修改异常的原因后,我们再来看看如何避免它。对于List来说,有两种方法:

  • 迭代器迭代元素,迭代器修改元素(ListIterator)
  • 集合遍历元素,集合修改元素(for)

也就是说,用集合遍历时(普通for)就用集合的方法去修改,用迭代器遍历时就用迭代器自带的方法修改。不要在迭代器遍历时,调用集合的方法修改元素。总之,不能混用。

安全的删除办法一:用迭代器遍历元素,用迭代器修改元素

复制代码
public class ForeachTest {
 
    public static void main(String[] args) {
 
        List<String> list = new ArrayList<>();
        list.add("a");
        list.add("b");
        list.add("c");
 
        Iterator<String> iterator = list.iterator();
        while (iterator.hasNext()) {
            String value = iterator.next();
            if ("a".equals(value)) {
                iterator.remove();
            }
        }
        System.out.println(list); // 输出[b, c]
    }
}

安全的删除办法二(养兔子的大叔提供):普通for遍历List,然后通过List删除元素

复制代码
public class ForeachTest {
 
    public static void main(String[] args) {
 
        List<String> list = new ArrayList<>();
        list.add("a");
        list.add("a");
        list.add("b");
        list.add("c");
 
        for (int i = list.size() - 1; i >= 0; i--) {
            if ("a".equals(list.get(i))) {
                list.remove(list.get(i));
            }
        }
        System.out.println(list);
    }
}

这里有一个细节,不知道大家是否注意到了:养兔子的大叔是倒序遍历的。

为什么倒序?因为顺序遍历时删除元素会有坑。

你会发现,当第一个a被删除后,会发生数组拷贝,后面的元素全部往前移动,而数组的指针(cursor)却往后移动,最终第二个a被跳过了。

**如果非要正序遍历又想避免跳过,**就要在每次删除元素后,都把for循环"往回拨一位":

复制代码
public class ForeachTest {
 
    public static void main(String[] args) {
 
        List<String> list = new ArrayList<>();
        list.add("a");
        list.add("a");
        list.add("b");
        list.add("c");
 
        for (int i = 0; i < list.size(); i++) {
            if ("a".equals(list.get(i))) {
                list.remove(list.get(i));
                i--; // 回拨指针
            }
        }
        System.out.println(list);
    }
}

观察JDK的Iterator的BUG

OK,现在让我们回到开头的问题。

一般来说,不建议使用增强for的同时用List#remove()移除元素,很大概率会发生并发修改异常。上面的代码是"凑巧"。

复制代码
public class ForeachTest {
 
    public static void main(String[] args) {
 
        List<String> list = new ArrayList<>();
        list.add("a");
        list.add("b");
        list.add("c");
 
        for (String value : list) {
            // a或c都会抛异常
            if ("a".equals(value)) {
                list.remove(value);
            }
        }
        System.out.println(list);
    }
}

我们可以再做一个实验:

复制代码
public class ForeachTest {
 
    public static void main(String[] args) {
 
        List<String> list = new ArrayList<>();
        list.add("a");
        list.add("b");
        list.add("c");
        list.add("d");
 
        for (String value : list) {
            // 只有c不会抛异常
            if ("c".equals(value)) {
                list.remove(value);
            }
        }
        System.out.println(list);
    }
}

看出问题了吗?

是的,只有倒数第二个才能"幸免于难"...

结合上面的内容,你应该已经猜到原因:

迭代的remove()底层是这样处理的:

  • 如果原本数组是[a,b,c]
  • 你移除了b,其实最终经过数组拷贝,会变成[a,c,c],也就是后面的部分元素往前挪了
  • 然后elementData[--size]会释放末尾那个元素,最终变成[a,c]

但问题在于迭代器此时再调用hasNext()时,确实没有元素了,因为刚才已经到第二个元素了,而现在只剩两个元素,所以会认为遍历结束了:

不调用next()意味着不会调用checkForComodification()去检查并发修改异常(虽然此时其实已经不一致)。

所以,这并不是JDK的bug,而是我们自己使用不当。迭代器遍历不应该使用List的remove,推荐interaror的remove。

其实有时候,IDEA会提示我们更优的写法:

底层其实就是迭代器。

相关推荐
海兰5 分钟前
使用 Spring AI 打造企业级 RAG 知识库第二部分:AI 实战
java·人工智能·spring
历程里程碑23 分钟前
二叉树---二叉树的中序遍历
java·大数据·开发语言·elasticsearch·链表·搜索引擎·lua
小信丶36 分钟前
Spring Cloud Stream EnableBinding注解详解:定义、应用场景与示例代码
java·spring boot·后端·spring
无限进步_40 分钟前
【C++】验证回文字符串:高效算法详解与优化
java·开发语言·c++·git·算法·github·visual studio
亚历克斯神41 分钟前
Spring Cloud 2026 架构演进
java·spring·微服务
七夜zippoe44 分钟前
Spring Cloud与Dubbo架构哲学对决
java·spring cloud·架构·dubbo·配置中心
海派程序猿44 分钟前
Spring Cloud Config拉取配置过慢导致服务启动延迟的优化技巧
java
阿维的博客日记1 小时前
为什么不逃逸代表不需要锁,JIT会直接删掉锁
java
William Dawson1 小时前
CAS的底层实现
java
九英里路1 小时前
cpp容器——string模拟实现
java·前端·数据结构·c++·算法·容器·字符串