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


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

    1. public class ForeachTest {
    2. public static void main(String[] args) {
    3. List list = new ArrayList<>();
    4. list.add("a");
    5. list.add("b");
    6. list.add("c");
    7. for (String value : list) {
    8. if ("b".equals(value)) {
    9. list.remove(value);
    10. }
    11. }
    12. System.out.println(list);
    13. }
    14. }

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

    复习并发修改异常

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

    1. public class ForeachTest {
    2. public static void main(String[] args) {
    3. List<Integer> list = new ArrayList<>();
    4. list.add(1);
    5. list.add(2);
    6. list.add(3);
    7. // 普通for
    8. plainForMethod(list);
    9. // 增强for,底层是迭代器
    10. foreachMethod(list);
    11. }
    12. private static void plainForMethod(List<Integer> list) {
    13. for (int i = 0; i < list.size(); i++) {
    14. System.out.println(list.get(i));
    15. }
    16. }
    17. private static void foreachMethod(List<Integer> list) {
    18. for (Integer integer : list) {
    19. System.out.println(integer);
    20. }
    21. }
    22. }

    直接用IDEA打开源码:

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

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

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

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

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

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

    1. public class ForeachTest {
    2. public static void main(String[] args) {
    3. List<String> list = new ArrayList<>();
    4. list.add("a");
    5. list.add("b");
    6. list.add("c");
    7. Iterator<String> iterator = list.iterator();
    8. while (iterator.hasNext()) {
    9. String value = iterator.next();
    10. if ("a".equals(value)) {
    11. iterator.remove();
    12. }
    13. }
    14. System.out.println(list); // 输出[b, c]
    15. }
    16. }

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

    1. public class ForeachTest {
    2. public static void main(String[] args) {
    3. List<String> list = new ArrayList<>();
    4. list.add("a");
    5. list.add("a");
    6. list.add("b");
    7. list.add("c");
    8. for (int i = list.size() - 1; i >= 0; i--) {
    9. if ("a".equals(list.get(i))) {
    10. list.remove(list.get(i));
    11. }
    12. }
    13. System.out.println(list);
    14. }
    15. }

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

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

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

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

    1. public class ForeachTest {
    2. public static void main(String[] args) {
    3. List<String> list = new ArrayList<>();
    4. list.add("a");
    5. list.add("a");
    6. list.add("b");
    7. list.add("c");
    8. for (int i = 0; i < list.size(); i++) {
    9. if ("a".equals(list.get(i))) {
    10. list.remove(list.get(i));
    11. i--; // 回拨指针
    12. }
    13. }
    14. System.out.println(list);
    15. }
    16. }

    观察JDK的Iterator的BUG

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

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

    1. public class ForeachTest {
    2. public static void main(String[] args) {
    3. List list = new ArrayList<>();
    4. list.add("a");
    5. list.add("b");
    6. list.add("c");
    7. for (String value : list) {
    8. // a或c都会抛异常
    9. if ("a".equals(value)) {
    10. list.remove(value);
    11. }
    12. }
    13. System.out.println(list);
    14. }
    15. }

    我们可以再做一个实验:

    1. public class ForeachTest {
    2. public static void main(String[] args) {
    3. List list = new ArrayList<>();
    4. list.add("a");
    5. list.add("b");
    6. list.add("c");
    7. list.add("d");
    8. for (String value : list) {
    9. // 只有c不会抛异常
    10. if ("c".equals(value)) {
    11. list.remove(value);
    12. }
    13. }
    14. System.out.println(list);
    15. }
    16. }

    看出问题了吗?

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

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

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

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

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

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

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

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

    底层其实就是迭代器。

  • 相关阅读:
    【Linux-Linux发展史和环境安装】开源的力量!
    c++排序算法
    穿戴式心电信号采集系统设计(任务书+lunwen+答辩PPt+查重报告)
    农产品直销平台/农场品销售系统
    umi - react web端 集成腾讯即时通信IM,实现自定义翻译功能
    D. Xenia and Colorful Gems(二分+暴力)
    java每日一练(4)
    关于git下载的项目,本地运行各类报错总结
    mybatis的问题
    Datawhale团队第八期录取名单!
  • 原文地址:https://blog.csdn.net/smart_an/article/details/134379839