草庐IT

面试 - 为什么foreach中不允许对元素进行add和remove

Q.E.D. 2023-04-04 原文

1、foreach遍历ArrayList过程中使用 add 和 remove

我们先来看看使用foreach遍历ArrayList过程中使用 add 和 remove 会出现什么样子的结果,然后再分析一下。

public static void main(String[] args) {
    List<Integer> list = new ArrayList<>();
    for (int i = 0; i < 20; i++) {
        list.add(i);
    }
    for (Integer j : list) {
        if (j.equals(3)) {
            list.remove(3);
        }
        System.out.println(j);
    }
}

运行结果:

0
1
2
3
Exception in thread "main" java.util.ConcurrentModificationException
	at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:911)
	at java.util.ArrayList$Itr.next(ArrayList.java:861)
	at test.Test.main(Test.java:12)

结果是出现了ConcurrentModificationException 异常,追踪下抛出异常的位置(ArrayList.java:911)

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

这个地方告诉我们如果 modCount 不等于 expectedModCount 的时候,就会抛出这个异常信息,那么这两个参数都代表了什么东西呢?为什么不相等的时候,就会出现异常呢?

2、追根溯源

2.1、modCount是什么?

这时候就要让我们去看源码了,在我们点到这个变量的时候,就会有注释告诉我们了 modCount 是 AbstractList 类中的一个成员变量,该值表示对List的修改次数。

这时候我们来看看 remove 方法中是否对这个变量进行了增减。

大家可以看到,在 remove 的方法中,实际上只是对 modCount 进行了++,那 expectedModCount 又是个什么东西呢?

2.2、expectedModCount 是什么?

expectedModCount 是 ArrayList 中的一个内部类——Itr中的成员变量,我们来看下怎么又扯出个内部类Itr。

通过反编译可以发现foreach编译后内部是使用迭代器实现的。

 迭代器是通过list.iterator()实例化的,list.iterator()就返回了一个内部类Itr的对象,从源码中可以看到Itr实现了Iterator接口,同时声明了expectedModCount这个成员变量, expectedModCount 表示对ArrayList修改次数的期望值,它的初始值为 modCount。

2.3、熟悉的checkForComodification方法

从源码可以看到这个类的next和remove方法里面都调用了一个checkForComodification方法,看到checkForComodification是不是很熟悉,这不就是异常的抛出位置吗。

checkForComodification方法是通过判断modCount和expectedModCount是否相等来决定是否抛出并发修改异常。

2.4、流程回顾

通过查看编译后的class文件,可以看出大致流程如下:当j为3时,调用了remove方法,remove方法中修改了modCount值,然后再输出j值,再进入下一次循环,此时hasNext为true,进入循环体第一行代码,调用next方法,next方法再调用checkForComodification方法,然后发现expectedModCount和modCount不一致,最终抛出ConcurrentModificationException 异常。

 也就是说,expectedModCount 初始化为 modCount 了,但是后面 expectedModCount 没有修改,而在 remove 和 add 的过程中修改了modCount ,这就导致了执行的时候,通过 checkForComodification 方法来判断两个值是否相等,如果相等了,那么没问题,如果不相等,那就给你抛出一个异常来。

而这也就是我们通俗说起来的 fail-fast 机制,也就是快速检测失败机制。

3、避免fail-fast 机制

3.1、使用listIterator或iterator

fail-fast 机制也是可以避免的,比如再拿出来我们上面的代码

public static void main(String[] args) {
    List<Integer> list = new ArrayList<>();
    for (int i = 0; i < 5; i++) {
        list.add(i);
    }

    System.out.println("没有删除元素前"+list.toString());
    // 迭代器使用listIterator和iterator均可
    ListIterator<Integer> listIterator = list.listIterator();
    while(listIterator.hasNext()){
        Integer integer = listIterator.next();
        if(integer==3){
            listIterator.remove();
            listIterator.add(9);
        }
    }
    System.out.println("删除元素后"+list.toString());
}

 这样的话,你就发现是可以运行的,也是没有问题的,我们看运行结果:

没有删除元素前[0, 1, 2, 3, 4]
删除元素后[0, 1, 2, 9, 4]

 结果也是显而易见的,我们实现了在 foreach 中进行 add 和 remove 的操作.

这里有个注意点,迭代器使用listIterator和iterator均可,看源码可以知道 listIterator其实使用的ListItr内部类,ListItr是继承了Itr类的,同时自己封了一些方法,例如add,hasPrevious,previous等等。所以代码中的remove方法是Itr类的,add方法是ListItr类的

 listIterator和iterator区别:

  1. 使用范围不同,Iterator可以应用于所有的集合,Set、List和Map和这些集合的子类型。而ListIterator只能用于List及其子类型。
  2. ListIterator有add方法,可以向List中添加对象,而Iterator不能。
  3. ListIterator和Iterator都有hasNext()和next()方法,可以实现顺序向后遍历,但是ListIterator有hasPrevious()和previous()方法,可以实现逆向(顺序向前)遍历。Iterator不可以。
  4. ListIterator可以定位当前索引的位置,nextIndex()和previousIndex()可以实现。Iterator没有此功能。
  5. 都可实现删除操作,但是ListIterator可以实现对象的修改,set()方法可以实现。Iterator仅能遍历,不能修改。

3.2、使用CopyOnWriteArrayList

CopyOnWriteArrayList 这个类也是能解决 fail-fast 的问题的,我们来试一下:

public static void main(String[] args) {
    CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
    for (int i = 0; i < 5; i++) {
        list.add(i);
    }
    System.out.println("没有删除元素前"+list.toString());
    for (Integer integer : list) {
        if(integer.equals(3)){
            list.remove(3);
            list.add(9);
        }
    }
    System.out.println("删除元素后"+list.toString());
}

 运行结果:

没有删除元素前[0, 1, 2, 3, 4]
删除元素后[0, 1, 2, 4, 9]

CopyOnWriteArrayList实现了对这个元素中间进行移除添加的操作,那么他的内部源码是怎么实现的,实际上很简单,复制

也就是他创建一个新的数组,再将旧的数组复制到新的数组上,但是为什么很少有人推荐这种做法,根本原因还是 复制

因为你使用了复制,那么就一定会出现有两个存储相同内容的空间,这样消耗了空间,最后进行 GC 的时候,那是不是也需要一些时间去清理他,所以个人不是很推荐,但是写出来的必要还是有的。

3.2.1、CopyOnWriteArrayList的add方法

public boolean add(E e) {
    // 可重入锁
    final ReentrantLock lock = this.lock;
    // 获取锁
    lock.lock();
    try {
        // 元素数组
        Object[] elements = getArray();
        // 数组长度
        int len = elements.length;
        // 复制数组
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        // 存放元素e
        newElements[len] = e;
        // 设置数组
        setArray(newElements);
        return true;
    } finally {
        // 释放锁
        lock.unlock();
    }
}

处理流程如下:

  • 获取锁(保证多线程的安全访问),获取当前的Object数组,获取Object数组的长度为length,进入步骤②。

  • 根据Object数组复制一个长度为length+1的Object数组为newElements(此时,newElements[length]为null),进入下一步骤。

  • 将下标为length的数组元素newElements[length]设置为元素e,再设置当前Object[]为newElements,释放锁,返回。这样就完成了元素的添加。

3.2.2、CopyOnWriteArrayList的remove方法

public E remove(int index) {
    // 可重入锁
    final ReentrantLock lock = this.lock;
    // 获取锁
    lock.lock();
    try {
        // 获取数组
        Object[] elements = getArray();
        // 数组长度
        int len = elements.length;
        // 获取旧值
        E oldValue = get(elements, index);
        // 需要移动的元素个数
        int numMoved = len - index - 1;
        if (numMoved == 0) // 移动个数为0
            // 复制后设置数组
            setArray(Arrays.copyOf(elements, len - 1));
        else { // 移动个数不为0
            // 新生数组
            Object[] newElements = new Object[len - 1];
            // 复制index索引之前的元素
            System.arraycopy(elements, 0, newElements, 0, index);
            // 复制index索引之后的元素
            System.arraycopy(elements, index + 1, newElements, index,
                                numMoved);
            // 设置索引
            setArray(newElements);
        }
        // 返回旧值
        return oldValue;
    } finally {
        // 释放锁
        lock.unlock();
    }
}

处理流程如下:

  1. 获取锁,获取数组elements,数组长度为length,获取索引的值elements[index],计算需要移动的元素个数(length - index - 1),若个数为0,则表示移除的是数组的最后一个元素,复制elements数组,复制长度为length-1,然后设置数组,进入步骤③;否则,进入步骤②
  2. 先复制index索引前的元素,再复制index索引后的元素,然后设置数组。
  3. 释放锁,返回旧值。

注意

CopyOnWriteArrayList解决 fail-fast 的问题不是通过迭代器来remove或add元素的,而是通过list本身的remove和add方法,所以add的元素位置也不一样,迭代器是当前位置后面一个,CopyOnWriteArrayList是直接放到最后。

有想法的同学可以看看CopyOnWriteArrayList的listIterator和iterator,其实是一样的,都是返回的COWIterator内部类。

 在COWIterator内部类中是不支持remove、set、add操作的,至少我使用的jdk1.8是不支持,会直接抛出UnsupportedOperationException异常:

 先写到这儿,后面有空再补充。

有关面试 - 为什么foreach中不允许对元素进行add和remove的更多相关文章

  1. ruby - 为什么我可以在 Ruby 中使用 Object#send 访问私有(private)/ protected 方法? - 2

    类classAprivatedeffooputs:fooendpublicdefbarputs:barendprivatedefzimputs:zimendprotecteddefdibputs:dibendendA的实例a=A.new测试a.foorescueputs:faila.barrescueputs:faila.zimrescueputs:faila.dibrescueputs:faila.gazrescueputs:fail测试输出failbarfailfailfail.发送测试[:foo,:bar,:zim,:dib,:gaz].each{|m|a.send(m)resc

  2. ruby-on-rails - Rails - 子类化模型的设计模式是什么? - 2

    我有一个模型:classItem项目有一个属性“商店”基于存储的值,我希望Item对象对特定方法具有不同的行为。Rails中是否有针对此的通用设计模式?如果方法中没有大的if-else语句,这是如何干净利落地完成的? 最佳答案 通常通过Single-TableInheritance. 关于ruby-on-rails-Rails-子类化模型的设计模式是什么?,我们在StackOverflow上找到一个类似的问题: https://stackoverflow.co

  3. ruby - 什么是填充的 Base64 编码字符串以及如何在 ruby​​ 中生成它们? - 2

    我正在使用的第三方API的文档状态:"[O]urAPIonlyacceptspaddedBase64encodedstrings."什么是“填充的Base64编码字符串”以及如何在Ruby中生成它们。下面的代码是我第一次尝试创建转换为Base64的JSON格式数据。xa=Base64.encode64(a.to_json) 最佳答案 他们说的padding其实就是Base64本身的一部分。它是末尾的“=”和“==”。Base64将3个字节的数据包编码为4个编码字符。所以如果你的输入数据有长度n和n%3=1=>"=="末尾用于填充n%

  4. ruby - 解析 RDFa、微数据等的最佳方式是什么,使用统一的模式/词汇(例如 schema.org)存储和显示信息 - 2

    我主要使用Ruby来执行此操作,但到目前为止我的攻击计划如下:使用gemsrdf、rdf-rdfa和rdf-microdata或mida来解析给定任何URI的数据。我认为最好映射到像schema.org这样的统一模式,例如使用这个yaml文件,它试图描述数据词汇表和opengraph到schema.org之间的转换:#SchemaXtoschema.orgconversion#data-vocabularyDV:name:namestreet-address:streetAddressregion:addressRegionlocality:addressLocalityphoto:i

  5. ruby - 为什么 4.1%2 使用 Ruby 返回 0.0999999999999996?但是 4.2%2==0.2 - 2

    为什么4.1%2返回0.0999999999999996?但是4.2%2==0.2。 最佳答案 参见此处:WhatEveryProgrammerShouldKnowAboutFloating-PointArithmetic实数是无限的。计算机使用的位数有限(今天是32位、64位)。因此计算机进行的浮点运算不能代表所有的实数。0.1是这些数字之一。请注意,这不是与Ruby相关的问题,而是与所有编程语言相关的问题,因为它来自计算机表示实数的方式。 关于ruby-为什么4.1%2使用Ruby返

  6. ruby - ruby 中的 TOPLEVEL_BINDING 是什么? - 2

    它不等于主线程的binding,这个toplevel作用域是什么?此作用域与主线程中的binding有何不同?>ruby-e'putsTOPLEVEL_BINDING===binding'false 最佳答案 事实是,TOPLEVEL_BINDING始终引用Binding的预定义全局实例,而Kernel#binding创建的新实例>Binding每次封装当前执行上下文。在顶层,它们都包含相同的绑定(bind),但它们不是同一个对象,您无法使用==或===测试它们的绑定(bind)相等性。putsTOPLEVEL_BINDINGput

  7. ruby - Infinity 和 NaN 的类型是什么? - 2

    我可以得到Infinity和NaNn=9.0/0#=>Infinityn.class#=>Floatm=0/0.0#=>NaNm.class#=>Float但是当我想直接访问Infinity或NaN时:Infinity#=>uninitializedconstantInfinity(NameError)NaN#=>uninitializedconstantNaN(NameError)什么是Infinity和NaN?它们是对象、关键字还是其他东西? 最佳答案 您看到打印为Infinity和NaN的只是Float类的两个特殊实例的字符串

  8. ruby-on-rails - 如果 Object::try 被发送到一个 nil 对象,为什么它会起作用? - 2

    如果您尝试在Ruby中的nil对象上调用方法,则会出现NoMethodError异常并显示消息:"undefinedmethod‘...’fornil:NilClass"然而,有一个tryRails中的方法,如果它被发送到一个nil对象,它只返回nil:require'rubygems'require'active_support/all'nil.try(:nonexisting_method)#noNoMethodErrorexceptionanymore那么try如何在内部工作以防止该异常? 最佳答案 像Ruby中的所有其他对象

  9. ruby - 为什么 SecureRandom.uuid 创建一个唯一的字符串? - 2

    关闭。这个问题需要detailsorclarity.它目前不接受答案。想改进这个问题吗?通过editingthispost添加细节并澄清问题.关闭8年前。Improvethisquestion为什么SecureRandom.uuid创建一个唯一的字符串?SecureRandom.uuid#=>"35cb4e30-54e1-49f9-b5ce-4134799eb2c0"SecureRandom.uuid方法创建的字符串从不重复?

  10. ruby-on-rails - s3_direct_upload 在生产服务器中不工作 - 2

    在Rails4.0.2中,我使用s3_direct_upload和aws-sdkgems直接为s3存储桶上传文件。在开发环境中它工作正常,但在生产环境中它会抛出如下错误,ActionView::Template::Error(noimplicitconversionofnilintoString)在View中,create_cv_url,:id=>"s3_uploader",:key=>"cv_uploads/{unique_id}/${filename}",:key_starts_with=>"cv_uploads/",:callback_param=>"cv[direct_uplo

随机推荐