Java的并发修改异常

作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO

联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬

学习必须往深处挖,挖的越深,基础越扎实!

阶段1、深入多线程

阶段2、深入多线程设计模式

阶段3、深入juc源码解析

阶段4、深入jdk其余源码解析

阶段5、深入jvm源码解析

引出问题

public class ForeachTest {
 
    public static void main(String[] args) {
 
        List 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);
    }
}

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

大部分人的回答是:会发生并发修改异常(ConcurrentModificationException)。

其实上面的代码会正常输出[a, c],而且IDEA还提示我们有更好的写法:

Java的并发修改异常_第1张图片

也就是:

public class ForeachTest {
 
    public static void main(String[] args) {
 
        List list = new ArrayList<>();
        list.add("a");
        list.add("b");
        list.add("c");
 		// 使用Java8新增的removeIf
        list.removeIf("b"::equals);
        System.out.println(list);
    }
}

现在摆在我们面前的问题变为:

  • 不是说增强for+list.remove()会发生并发修改异常吗?怎么上面的代码“安然无恙”?
  • 为什么推荐使用removeIf(),它好在哪?

在回答这两个问题之前,我们先复习一下什么是并发修改异常,以及什么时候会出现并发修改异常。

什么是并发修改异常,如何避免?

public class ForeachTest {
 
    public static void main(String[] args) {
 
        List list = new ArrayList<>();
        list.add(1);
        list.add(2);
        list.add(3);
 
        // 普通for
        plainForMethod(list);
        // 增强for,底层是迭代器
        foreachMethod(list);
    }
 
    private static void plainForMethod(List list) {
        for (int i = 0; i < list.size(); i++) {
            System.out.println(list.get(i));
        }
    }
 
    private static void foreachMethod(List list) {
        for (Integer integer : list) {
            System.out.println(integer);
        }
    }
}

对上面的代码进行反编译:

Java的并发修改异常_第2张图片

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

Java的并发修改异常_第3张图片

Java的并发修改异常_第4张图片

那么什么时候“版本号”modCount会改变呢?增删都会改变modCount的值(注意,删除也是modCount++,版本号只能递增):

Java的并发修改异常_第5张图片

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

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

也就是说,用集合遍历时(普通for)就用集合的方法去修改,用迭代器遍历时就用迭代器自带的方法修改,不能混用。接下来,我们一起分析一下上面两种方法背后的原理。

用迭代器遍历元素,用迭代器修改元素

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

为什么iterator.remove()不会引发ConcurrentModificationException呢?

正如刚才所说,增强for底层是Iterator,list.remove()会修改modCount,而Iterator的next()会去checkForComodification(),一旦modCount!=expectedModCount就会抛异常。

上面的代码中,next()仍在调用,也就是说还是List还是会在遍历时检查modCount,那么不发生并发修改异常的原因只有一个:

不同于List的remove,Iterator提供的remove等增改操作会让expectedModCount与modCount保持同步,即expectedModCount始终等于modCount。

Java的并发修改异常_第6张图片

这样一来,即使下一轮遍历next()内部仍旧检测版本号,但由于两个数值始终相等,所以不会抛异常。

普通for遍历List,然后通过List删除元素

集合遍历+集合删除则完全脱离了List版本号的约束:遍历用的是普通for循环,根本不会检测版本号,所以即使list.remove()确实把modCount改得面目全非,也不会被检测到。

public class ForeachTest {
 
    public static void main(String[] args) {
 
        List 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);
    }
}

这里有一个细节,不知道大家是否注意到了:for循环是倒序的。

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

Java的并发修改异常_第7张图片

Java的并发修改异常_第8张图片

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

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

public class ForeachTest {
 
    public static void main(String[] args) {
 
        List 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);
    }
}

回答开头的问题

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

public class ForeachTest {
 
    public static void main(String[] args) {
 
        List 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 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);
    }
}

看出问题了吗?

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

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

Java的并发修改异常_第9张图片

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

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

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

Java的并发修改异常_第10张图片

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

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

至于IDEA为什么推荐使用List#removeIf(),我们可以看看removeIf()是怎么实现的:

Java的并发修改异常_第11张图片

底层其实就是迭代器遍历+迭代器remove,Iterator#remove()会把modCount重新赋值给expectedModCount,所以两者始终一致。

如果看完这篇文章你只能记住一件事,请记得:

需要删除List元素时,尽量使用迭代器,或者普通for倒序删除。

你可能感兴趣的:(面试,java,并发异常,面试题)