Java并发修改异常的源码解析

【传智播客.黑马程序员训练营成都中心】

  • 转载请注明出处

作者:成都校区.堂堂老师

1. 什么时候会产生并发修改异常

  • 并发的意思是同时发生,那么其实并发修改的字面意思就是同时修改,通过查看JDK的API我们可以得知,并发修改异常的出现的原因是:当方法检测到对象的并发修改,但不允许这种修改时,抛出此异常。

  • 一个常见的场景就是:当我们在对集合进行迭代操作的时候,如果同时对集合对象中的元素进行某些操作,则容易导致并发修改异常的产生。

    • 例如我们要完成以下需求:
    • 在一个存储字符串的集合中,如果存在字符串"Java",则添加一个"Android"
    • 示范代码如下:
     public class Test {
         public static void main(String[] args){
             ArrayList<String> list = new ArrayList<String>();
             list.add("Java");
             list.add("Hello");
             list.add("World");
             
    
             Iterator<String> it = list.iterator();//获取迭代器对象
    
             while(it.hasNext()){ //如果迭代器判断集合中还有下一个元素则继续循环
                 
                 String str = it.next();//获取集合中迭代器所指向的元素
    
                 if(str.equals("Java")) {//如果这个元素内容是"Java"
                     list.add("Android");//则在集合中添加一个"Android"
                 }
             }
         }
     }
    
    • 控制台输出:
     Exception in thread "main" java.util.ConcurrentModificationException
     at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:859)
     at java.util.ArrayList$Itr.next(ArrayList.java:831)
     at com.itheima.day02.Test5.main(Test5.java:17)
    
  • 控制台显示的ConcurrentModificationException,即并发修改异常

  • 下面我们就以ArrayList集合中出现的并发修改异常为例来分析异常产生的原因。

2. 异常是如何产生的

  • 2.1 想要知道异常出现的原因,我们需要找到源码中异常出现的根源

    • 我们能通过控制台找到异常的根源:

      • at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:859)
      • 异常出现的位置出现在ArrayList类中内部类Itr中的checkForComodification方法
    • 贴出此方法的源码:

        final void checkForComodification() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
        }
      
    • 由此方法可知,当一个名为modCount的变量值不等于expectedModCount的变量值时,异常对象被抛出。

  • 2.2 继续探究这两个变量分别是代表什么

    • modCount
      • modCount是定义在AbstractList抽象类中的public修饰的成员变量,而ArrayList是此类的子类,那么代表ArrayList继承到了modCount这个变量。

      • 源码中对modCount的解释是:

        The number of times this list has been <i>structurally modified</i>

        - 我们可以理解为:这个变量其实就代表了集合在结构上修改的次数


        - expectedModCount
        - expectedModCount是内部类Itr中的成员变量,当ArrayList对象调用iteroter()方法时,会创建内部类Itr的对象,并给其成员变量expectedModCount赋值为ArrayList对象成员变量的值modCount。

        以下是内部类Itr的部分源码

          private class Itr implements Iterator<E> {
              int cursor;      
              int lastRet = -1; 
              int expectedModCount = modCount;
              ....
        
        • 由此可知,当Itr对象被创建的时候,expectedModCount的值会等于modCount变量的值。

    • 那么modCount变量在赋值给expectedModCount之前又会如何变化呢?
      • 当我们创建ArrayList对象的时候,ArrayList对象里包含了此变量modCount并且初始化值为0;

      • 通过查看源码,我们能发现在ArrayList类中有操作modCount的方法都是添加元素的相关功能和删除元素的相关功能。例如:

        • 每删除一个元素,modCount的值会自增一次
          public E remove(int index) {
              rangeCheck(index);
              modCount++;
              ...//此处省略代码
              E oldValue = elementData(index);
              return oldValue;
          }
        
        • 在add方法中会调用下面的方法,意味着每添加一个元素,modCount的值也会自增一次
          private void ensureExplicitCapacity(int minCapacity) {
                  modCount++;
                  if (minCapacity - elementData.length > 0)
                      grow(minCapacity);
          }
        
      • 也就是说:我们每次进行对集合中的元素个数变化的操作时,modCount的值就会+1

        • 但是这个操作仅限于增删元素,修改元素值并不会影响modCount的值
      • 再结合API中对此变量的解释,我们可以得出大致的判断:

        • 其实modCount变量就是记录了对集合元素个数的改变次数

  • 2.3 分析完这两个关键的变量,我们再结合迭代器的工作流程来分析异常出现的过程

    • 2.3.1 迭代器的创建
      • 上文中已经提到过,当ArrayList对象调用iteroter()方法时,会创建内部类Itr的对象。

      • 此时迭代器对象中有两个最关键的成员变量:cursor、expectedModCount

          private class Itr implements Iterator<E> {
          int cursor;       // index of next element to return
          int lastRet = -1; // index of last element returned; -1 if no such
          int expectedModCount = modCount;
          .....//此处省略下方其他源码
          }
        
      • cursor

        • 迭代器的工作就是将集合中的元素逐个取出,而cursor就是迭代器中用于指向集合中某个元素的指针
        • 在迭代器迭代的过程中,cursor初始值为0,每次取出一个元素,cursor值会+1,以便下一次能指向下一个元素,直到cursor值等于集合的长度为止,从而达到取出所有元素的效果。
      • expectedModCount

        • expectedModCount在迭代器对象创建时被赋值为modCount
        • 上文已经分析过,modCount应该理解为集合元素个数的改变次数,或者说结构修改次数
        • 也就是说,当创建完迭代器对象后,如果我们没有对集合结构进行修改,expectedModCount的值是会等于modCount的值的。
        • 在迭代集合元素的过程中,迭代器通过检查expectedModCount和modCount的值是否相同,以防止出现并发修改。

    • 2.3.2 迭代器迭代过程源码分析:
      • 在2.3.1中我们已经简要的分析过了迭代器工作中最重要的两个变量,下面贴出更多源码结合上文的分析继续说明迭代器是如何工作的。
        • 我们在使用迭代器的时候,一般会调用迭代器的hasNext()方法判断是否还有下一个元素,此方法源码非常简单:

            public boolean hasNext() {
                return cursor != size;
            }
          

          分析:
          - cursor初始值是0,默认指向集合中第一个元素,每次取出一个元素,cursor值就会自增一次
          - size是集合中的成员变量,用于表示集合的元素个数
          - 因为集合中最后一个元素的索引为size-1,只要cursor值不等于size那么就证明还有下一个元素,此时hasNext方法返回true,如若cursor值与size相等了,那么证明已经迭代完了最后一个元素,此方法返回false。


          - 当我们通过迭代器的hasNext方法返回true值确信集合中还有元素的时候,通常我们会通过迭代器的另一个方法next取出此元素。源码如下:

            public E next() {
                checkForComodification();
                int i = cursor;
                if (i >= size)
                    throw new NoSuchElementException();
                Object[] elementData = ArrayList.this.elementData;
                if (i >= elementData.length)
                    throw new ConcurrentModificationException();
                cursor = i + 1;
                return (E) elementData[lastRet = i];
            }
            
            //在next方法的第一行调用了此方法
            final void checkForComodification() {
                if (modCount != expectedModCount)
                    throw new ConcurrentModificationException();
            }
          

          分析:
          - next()方法第一行就是调用checkForComodification()方法,也就是我们上文中分析过并发修改异常出现根源
          - 当迭代器通过next()方法返回元素之前都会检查集合中的modCount和最初赋值给迭代器的expectedModCount是否相等,如果不等,则抛出并发修改异常。
          - 也就说,当迭代器工作的过程中,不允许集合擅自修改集合结构,如果修改了会导致modCount值变化,从而不会等于expectedModCount,那么迭代器就会抛出并发修改异常。
          - 如果没有异常产生,next()方法最后一行会返回cursor指向的元素。

3. 并发修改异常的作用及解决方案

  • 3.1 在上文中我们已经结合源码仔细的分析了并发修改异常产生的原因以及过程,那么这个异常的产生对程序而言究竟有什么意义呢?
    • 我们通过上文的分析其实可以知道,迭代器是通过cursor指针指向对应集合元素来挨个获取集合中元素的,每次获取对应元素后cursor值+1指向下一个元素,直到集合最后一个元素。
    • 那么如果在迭代器获取元素的过程中,集合中元素的个数突然改变,那么下一次获取元素时,cursor能否正确的指向集合的下一个元素就变得未知了,这种不确定性有可能导致迭代器工作出现意想不到的问题。
    • 为了防止在将来某个时间任意发生不确定行为的风险,我们在使用迭代器的过程中不允许修改集合结构(也可以说是不允许修改元素个数),否则迭代器会抛出异常结束程序。

  • 3.2 那如果如果遇到需要在遍历集合的同时修改集合结构的需求如何处理?
    • 3.2.1 在迭代器迭代的过程中,我们虽然不能通过集合直接增删元素,但是其实迭代器中是有这样的方法可以实现增删的。
      • 通过ArrayList中iterator()方法返回的Itr迭代器对象包含有一个remove方法:

          public void remove() {
              if (lastRet < 0)
                  throw new IllegalStateException();
              checkForComodification();
        
              try {
                  ArrayList.this.remove(lastRet);
                  cursor = lastRet;
                  lastRet = -1;
        
                  expectedModCount = modCount;
        
              } catch (IndexOutOfBoundsException ex) {
                  throw new ConcurrentModificationException();
              }
          }
        
      • 除了通过iterator()方法返回的Itr迭代器对象之外,我们可以获取Itr迭代器的子类对象ListItr,ListItr中有添加元素的add方法:

          public void add(E e) {
              checkForComodification();
        
              try {
                  int i = cursor;
                  ArrayList.this.add(i, e);
                  cursor = i + 1;
                  lastRet = -1;
        
                  expectedModCount = modCount;
              } catch (IndexOutOfBoundsException ex) {
                  throw new ConcurrentModificationException();
              }
          }
        
    • 以上两个方法在增删完元素后都对指针cursor进行了相应的处理,避免了出现迭代器获取元素的不确定行为。

    • 3.2.2 异常是迭代器抛出的,那么我们除了可以使用迭代器遍历集合,还可以使用其他方法,比如:
      • 属于List体系的集合我们可以使用用普通for循环,通过索引获取集合元素的方法来遍历集合,这个时候修改集合结构是不会出现异常的。

          public static void main(String[] args){
              ArrayList<String> list = new ArrayList<String>();
              list.add("Java");
              list.add("Hello");
              list.add("World");
              
              for (int i = 0; i < list.size(); i++) {
                  String element = list.get(i);
                  if(element.equals("Java")){
        
                      /* 注意:
                       * 当集合中增删元素后 i 索引的指向元素有可能发生变化,
                       * 我们通常会在增删元素的同时让i变量也随之变化,
                       * 从而使 i 能正确指向下一个元素:list.remove(i--);
                       */
                      list.remove(i);
                  }
              }
          }
        
      • 那么不属于List体系的集合,我们也可通过单列集合顶层接口Collction中定义过的toArray方法将集合转为数组,这个时候就不需要担心出现并发修改异常了。

4. 其他相关问题

  • 4.1 foreach循环和迭代器
    • foreach循环也就是我们常说的增强for循环,其实foreach循环的底层是用迭代器实现的

    • 我们可以通过断点调试操作如下范例代码证明上面的观点:

        public static void main(String[] args){
            ArrayList<String> list = new ArrayList<String>();
            list.add("Java");
            list.add("Hello");
            list.add("World");
            
            for (String s : list) {
                System.out.println(s);//在此行代码打上断点,然后开启debug运行程序
            }
            
        }
      
      • 在输出语句这一行打上断点,当程序执行到输出语句这一行时,eclipse跳入debug视图
      • 接着按下F6结束这一步,debug上显示执行for循环上的代码,此时按下F5进入代码,会发现程序的执行来到了ArrayList类中内部类Itr中的hasNext()方法中。
        - 由此可见,foreach循环底层是用迭代器来实现的。
    • 既然foreach底层是用迭代器实现的,那么就意味着:

      • 我们不能在foreach中对集合结构进行修改。否则有可能出现并发修改异常

  • 4.2 当迭代至集合倒数第二个元素的同时,删除集合元素不会导致并发修改异常
    • 这是一个很有意思的问题,我们先来一段范例代码:

        public static void main(String[] args){
            ArrayList<String> list = new ArrayList<String>();
            list.add("Java");
            list.add("Hello");
            list.add("World");
            
            for (String s : list) {
                if(s.equals("Hello")){
                    list.remove("Java");
                }
            }
            
            System.out.println(list);//控制台输出:[Hello, World]
        }
      
      • 上面的代码在foreach中当迭代至到处第二个元素"Hello"的时候,我们删除了元素"Java",但是并没有出现并发修改异常,控制台输出了剩余的两个元素也证明这次删除确实成功了。
      • 如果不是迭代至倒数第二个元素时删除元素同样会导致异常的产生,这又是为什么呢?
    • 原因解释:

      • 集合中倒数第二个元素的索引为size - 2,当迭代器取出集合倒数第二个元素的时候,cursor指向的位置会向右移动一位,值会变为size - 1;
      • 如果此时通过集合去删除一个元素,集合中元素个数会减一,所以size值会变为size - 1;
      • 当迭代器试图去获取最后一个元素的时候,会先判断是否还有元素,调用hasNext()方法,上文中已经分析过,hasNext()方法会返回cursor!=size,但是此时的cursor和此时的size值都等于删除之前的size - 1,两者相等,那么hasNext()方法就会返回false,迭代器就不会再调用next方法获取元素了。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容