跳至主要內容

快速失败机制和其漏洞

ruleeeer原创Java集合迭代器大约 6 分钟约 1754 字

该部分来源于一次在某论坛上的讨论,迭代器在某些特殊的情况下即使修改了集合,可能并不会发生快速失败的现象。

快速失败是什么?

java.util 包下的集合类都是快速失败的,快速失败表示的是在集合迭代的过程进行某些操作(增加,修改,删除),可能会导致ConcurrentModificationException,也就是并发修改异常,但是注意该机制只是用来提醒集合可能在迭代的时候发生了修改,不要用该机制来完成对集合是否有其他线程修改的判断,因为该机制不够完全准确。

如何实现该机制?

ArrayList为例,因为(增加、修改、删除)方法会出发该机制,所以首先看看该集合的add()方法的源代码

image-20210214231746188

add()方法很明显第一行是确认容量和扩容的逻辑,我们进入第一行实现

image-20210214232047285

从图中可以看出,其实235到239就是该方法主要实现,请关注红色方框中的内容,这个modCount是什么?感觉和扩容方法没什么关系,继续跟踪modCount

image-20210214232245906

该字段上的注释已经足够能说明问题了,我给出翻译方便观看,翻译直接来源于机翻。

已对该列表进行结构修改的次数。 结构修改是指更改列表大小或以其他方式干扰列表的方式,即正在进行的迭代可能会产生错误的结果。 该字段由iterator和listIterator方法返回的迭代器和列表迭代器实现使用。 如果此字段的值意外更改,则迭代器(或列表迭代器)将抛出ConcurrentModificationException以响应next , remove , previous , set或add操作。 面对迭代期间的并发修改,这提供了快速故障行为,而不是不确定的行为。 子类对该字段的使用是可选的。 如果子类希望提供快速失败的迭代器(和列表迭代器),则只需在其add(int, E)和remove(int)方法(以及任何其他覆盖该方法导致结构化的方法remove(int)递增此字段即可。修改列表)。 一次调用add(int, E)或remove(int)不得在此字段中添加多个,否则迭代器(和列表迭代器)将抛出ConcurrentModificationExceptions 。 如果实现不希望提供快速失败迭代器,则可以忽略此字段。

从上边JDK附带的字段注释来看至少能知道两点:

  • 只有使用迭代器的情况下才会出现ConcurrentModificationException,使用普通的for循环是不会触发该机制的。
  • 修改之后并不是立即报错,而是在next、remove、previous、hasNext等方法执行时才会报错。

普通的for循环删除元素会发生什么?

首先来验证一下普通的for循环是否会抛出异常,代码如下

        List<String> list = new ArrayList<String>() {{
            add("张三");
            add("李四");
            add("王五");
        }};
        for (int i = 0; i < list.size(); i++) {
            list.remove(i);
        }
        System.out.println("删除后元素为" + list);

image-20210215171416888

可以看到并没有抛出异常,但是会发现元素并没有被完全删除,此时遗留一个“李四”依然在list中,这是因为第一次删除index为0的元素(也就是“张三”),此时list.size()=2,因为删除了一个元素,此时index为0的元素是“李四”,index为1的元素为“王五”,然后index++之后为1,删除1号位元素“王五”,注意,此时“李四”元素被跳过,因为“李四”的index=0。

文字可能不够清晰,下面图示

image-20210215171723145

如此看来,使用普通的for循环删除元素并不会出现ConcurrentModificationException,但是如果是根据下标删除需要注意下标问题,否则可能会遗漏元素。

迭代器是如何响应删除的?

下边来看看迭代器相关的代码,首先迭代器一般会作为容器的内部类实现,例如ArrayList的迭代器。

image-20210215173857124

可以看到,实际上每次创建一个迭代器(list.iterator())时,实际上是调用了迭代器的构造方法,注意红色方框中的代码,迭代器每次创建时会将ArrayListmodCount复制到迭代器一份。

再来看看相关的其它方法

image-20210215174150247

还是注意红色方框中的方法,这两个方法在next()remove()中都有使用到,看到方法名称想必已经能判断出这到底是用来干什么的,同时注意另一个点,迭代器的remove()方法将会重新同步ArrayListmodCount到迭代器中。

下面是checkForComodification()方法的源代码。

image-20210215174436087

该方法简单明了,就是判断容器的modCount和迭代器自身的expectedModCount是否一致,不一致就直接抛出ConcurrentModificationException

容器的remove()和迭代器的remove()流程图如下所示

image-20210215180745979

一种例外的情况

 				// 初始化数据
				List<String> list = new ArrayList<String>() {{
            add("张三");
            add("李四");
            add("王五");
        }};
				
        Iterator<String> iterator = list.iterator();
				// 使用迭代器循环,使用容器的remove()方法移除元素
        while (iterator.hasNext()) {
            String next = iterator.next();
            if ("李四".equals(next)) {
                list.remove("李四");
            }
        }

运行上边这段代码实际上不会发生ConcurrentModificationException异常,准确来说并是不快速失败没有起效,而是根本就没有发生快速失败,以上的代码循环体中快速失败的检测在于iterator.next()方法中,源代码如下

该方法第一行就会进行modCount==expectModCount的检测,所以准确来说只要在删除“李四”之后执行过next()方法就一定会出现并发修改异常,但是很可惜,第三次的next()方法并不会被执行,我们可以查看一下循环的判断条件hasNext()方法的源代码。

image-20210216100738578

只有短短的一行,但足以说明问题了,cursor实际上就是当前循环的元素的index+1,默认是0,每循环一次就会自增一次,size是集合的容量,由此可知在删除完毕第二个元素“李四”时cursor为2,此时由于删除了一个元素,所以集合的size为2,也就不再满足hasNext()方法的条件cursor != size了,所以根本不会进行modCount == expectModCount的检查,自然也就不会出现并发修改异常了。

综上,如果我们在迭代时删除集合的倒数第二个元素,也就是size-2处的元素,并不会出现并发修改异常。

上次编辑于:
贡献者: ruleeeer