我是靠谱客的博主 传统吐司,最近开发中收集的这篇文章主要介绍ArrayList为什么线程不安全?说说foreach与iterator时remove的区别,觉得挺不错的,现在分享给大家,希望可以做个参考。

概述

文章目录

    • 1. ArrayList 和 LinkedList的区别
      • 1.1 ArrayList为什么线程不安全?
        • ①:多线程add元素时,可能出现下表越界异常ArrayIndexOutOfBoundsException
        • ②:多线程add元素时,数组元素可能发生抢占式替换,导致某个元素为null
      • 1.2 CopyOnWriteArrayList怎么保证线程安全?
      • 1.3 Vector、ArrayList、LinkedList的区别是什么?
    • 2. 构造器
    • 3. add方法
    • 4. get方法
    • 5. remove方法
    • 6. foreach时remove/add抛异常的原因!
    • 7. 为什么使用iterator迭代器remove/add元素,不会抛异常?


1. ArrayList 和 LinkedList的区别

        ArrayList作为最基础的集合类,其底层是使用一个动态数组来实现的,这里“动态”的意思是可以动态扩容(注意不会动态缩容)。与HashMap不同的是,ArrayList使用的是1.5倍的扩容策略,而HashMap使用的是2倍的方式;ArrayList的默认初始容量为10,而HashMap为16。

        在Java 7之前的版本中,ArrayList的无参构造器是在构造器阶段完成的初始化;而从Java 7开始,改为了在add方法中完成初始化,也就是所谓的延迟初始化。在HashMap中也有同样的设计思路。另外,同HashMap一样,如果要存入一个很大的数据量并且事先知道要存入的这个数据量的固定值时,就可以往构造器里传入这个初始容量,以此来避免以后的频繁扩容。

面试题:ArrayList 和 LinkedList的区别,点此查看!

ArrayList中为什么elementData被关键字transient所修饰?

        

1.1 ArrayList为什么线程不安全?

①:多线程add元素时,可能出现下表越界异常ArrayIndexOutOfBoundsException

首先在看一下ArrayList中两个重要元素

  • elementData:用来保存添加进来的元素

  • size:用来记录elementData中存储的元素个数

    public class ArrayList<E> extends AbstractList<E>
     implements List<E>, RandomAccess, Cloneable, java.io.Serializable
    {
     /**
     * 列表元素集合数组如果新建ArrayList对象时没有指定大小,那么会将 
     * EMPTY_ELEMENTDATA赋值给elementData,
     * 并在第一次添加元素时,将列表容量设置为DEFAULT_CAPACITY 
     */
     transient Object[] elementData; 
     // 列表大小,elementData中存储的元素个数
     private int size;
    }
    
    

在调用add方法往数组中添加一个元素时,有几个大的步骤

  1. 先把size的长度加1,然后判断与当前elementData 数组长度比较,看是否需要扩容。

  2. elementData 对应位置上设置值。

        public boolean add(E e) {
        	//size长度+1
            ensureCapacityInternal(size + 1);  
            // 在 elementData 对应位置上设置值。
            elementData[size++] = e;
            return true;
        }
    
        private void ensureExplicitCapacity(int minCapacity) {
            modCount++;
    		// minCapacity = size + 1 ,与当前数组长度比较,看是否需要扩容
            if (minCapacity - elementData.length > 0)
                grow(minCapacity); // 扩容
        }
    

这种 比较 + 赋值 的操作在高并发情况下,会出现线程安全问题!

比如:ArrayList 默认数组大小为 10。假设现在已经添加进去 9 个元素了,size = 9

  1. 线程 A 执行完 add 方法中的 ensureCapacityInternal(size + 1) 后,挂起了,cpu调度到线程 B
  2. 线程 B 开始执行,校验数组容量发现不需要扩容。于是把 “b” 放在了下标为 9 的位置,且 size 自增 1。此时 size = 10
  3. 线程 A 接着执行,尝试把 “a” 放在下标为 10 的位置(因为 size 被B加了1,所以 size = 10)。但因为数组还没有扩容,最大的下标才为 9,所以会抛出数组越界异常ArrayIndexOutOfBoundsException

        

②:多线程add元素时,数组元素可能发生抢占式替换,导致某个元素为null

add方法中的elementData[size++] = e 这条赋值代码,依然是线程不安全的,因为 size++并不是一个原子操作,在执行引擎执行 size++操作时,会有多步操作

  1. 先赋值:elementData[size] = e
  2. 再加一:size = size + 1

         在单线程执行这两条代码时没有任何问题,但是当多线程环境下执行时,可能就会发生一个线程的值覆盖另一个线程添加的值,具体逻辑如下:

  1. 列表大小为 0,即size=0
  2. 线程 A 开始添加一个元素,值为 a。此时它执行第一条操作elementData[size] = e,将 a 放在了 elementData 下标为 0 的位置上。
  3. 接着线程 B 刚好也要开始添加一个值为 b 的元素,且走到了第一步操作。此时线程 B 获取到 size 的值依然为 0,于是它将 b 也放在了 elementData 下标为 0 的位置上。
  4. 线程 A 开始执行size = size + 1, size 的值为1( 0+1 = 1)。
  5. 线程 B 开始执行size = size + 1, size 的值为2( 1+1 = 2), ( 因为A执行0+1之后,size已经变为1了)。
  6. 这样线程 AB 执行完毕后:
    • 理想中情况为 size 为 2,elementData 下标 0 的位置为 a,下标 1 的位置为 b。
    • 而实际情况变成了 size 为 2,elementData 下标为 0 的位置变成了 b,下标 1 的位置上什么都没有。

        并且后续除非使用 set 方法修改下标 1的值,否则将一直为 null,因为 size 为 2,添加元素时会从下标为 2 的位置上开始,造成这种结果的原因就是elementData[size++] = e 并不是原子操作,多线程时发生了抢占式替换!

案例如下:

  public static void main(String[] args) {


        // ArrayList 测试并发安全问题, 有可能发生替换了 ,某个元素为null
        final List<Integer> list = new ArrayList<Integer>();
        try {
            // 线程A将0-1000添加到list
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 1000; i++) {
                        list.add(i);
                        try {
                            Thread.sleep(1);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }).start();
            // 线程B将1000-2000添加到列表
            new Thread(new Runnable() {
                public void run() {
                    for (int i = 1000; i < 2000; i++) {
                        list.add(i);
                        try {
                            Thread.sleep(1);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }).start();
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 打印所有结果
        for (int i = 0; i < list.size(); i++) {
            System.out.println("第" + (i + 1) + "个元素为:" + list.get(i));
        }
    }

打印结果:

在这里插入图片描述

出现这种情况之后,如果在生产中使用 Mybatis-plus 的saveBatch等批处理操作时,由于list集合中某个元素为null,就会报空指针异常!

        

1.2 CopyOnWriteArrayList怎么保证线程安全?

        普通的ArrayList在多线程并发读写时,会出现并发修改异常。原因在于ArrayList存在一种快速失败机制,这种机制使读数据的时候不允许发生修改。比如:A线程原本读到数组有5个长度,B线程对其做了删除数据的操作,数组长度变为4个,那么A线程在遍历长度为5的数组时就会抛异常,所以ArrayList不允许多线程读写数据!

那么CopyOnWriteArrayList又是如何保证多线程数据安全的呢?

        CopyOnWriteArrayList在进行add操作时,不在原数组上修改,而是先加锁(防止并发写),然后复制一份新的数组,并把长度加1,用这个新数组来新增或者删除数据,也被称为写时复制(以空间换时间!)。读操作不加锁,在保证效率的同时也避免ArrayList多个线程操作一个数组产生的并发修改异常问题。但这样做也存在一些问题:

  1. 写操作需要复制一下原数组,写入成功后,原数组变为垃圾对象。当数据量过大的时候,会导致GC频率激增,所以CopyOnWrite适用于读多写少的情况
  2. 在写的过程中,原有的读的数据是不会发生更新的,只有新的读才能读到最新数据,所以使用CopyOnWrite会有脏数据,因为CopyOnWrite只保证最终一致性!

在这里插入图片描述

写操作加ReentrantLock,写时复制!

    public boolean add(E e) {
    	// 添加元素时使用的ReentrantLock加锁,保证线程安全
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            //复制一个array的副本,长度+1,目的是存储新增的元素
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            //往副本里写入
            newElements[len] = e;
            //副本替换原本,成为新的原本
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }
    }

读操作不加锁


public E get(int index)  { 
	 return get(getArray(), index); //无锁 21
}

 

1.3 Vector、ArrayList、LinkedList的区别是什么?

  1. VectorArrayList都是以类似数组的形式存储在内存中,LinkedList则以链表的形式进 行存储。
  2. List中的元素有序、允许有重复的元素,Set中的元素无序、不允许有重复元素。
  3. Vector线程同步,ArrayList、LinkedList线程不同步。
  4. LinkedList适合指定位置插入、删除操作,不适合查找;ArrayList、Vector适合查找, 不适合指定位置的插入、删除操作。
  5. ArrayList在元素填满容器时会自动扩充容器大小的50%,而Vector则是100%,因此 ArrayList更节省空间。


2. 构造器

ArrayList 的构造器的构造器有两种,一种无参构造器,一种有参构造器

/**
 * 无参构造器
 */
public ArrayList() {
    //DEFAULTCAPACITY_EMPTY_ELEMENTDATA是一个空实现“Object[] = {}”,这里也就是在做初始化一个对象数组
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
 
/**
 * 有参构造器
 */
public ArrayList(int initialCapacity) {
    if (initialCapacity > 0) {
        //如果传入的容量>0就按照这个容量来初始化数组
        this.elementData = new Object[initialCapacity];
    } else if (initialCapacity == 0) {
        //如果传入参数为0 ,EMPTY_ELEMENTDATA也是一个空实现“Object[] = {}””,这里也是在做初始化一个对象数组
        this.elementData = EMPTY_ELEMENTDATA;
    } else {
        //如果传入参数为负数,则抛出异常
        throw new IllegalArgumentException("Illegal Capacity: " +
                initialCapacity);
    }
}

上边说到,jdk1.8的数组容量是再add方法中初始化的,接下来看add方法


3. add方法

3.1 在末尾直接添加元素

    public boolean add(E e) {
    	//查看是否需要扩容
        ensureCapacityInternal(size + 1);  // Increments modCount!!

		//在末尾处添加元素
        elementData[size++] = e;
        return true;
    }


================ensureCapacityInternal(size + 1)代码如下==============

    private void ensureCapacityInternal(int minCapacity) {
	    /*
	    minCapacity = size + 1
	    之前说过,DEFAULTCAPACITY_EMPTY_ELEMENTDATA是一个空实现“{}”
	    这里也就是在判断是不是调用的无参构造器。
	     */
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
         	/*
	        如果是的话就返回DEFAULT_CAPACITY(10)和size+1之间的较大者。也就是说,数组的最小容量是10
	        这里有意思的一点是:调用new ArrayList<>()和new ArrayList<>(0)两个构造器会有不同的默认容量(在HashMap中
	        也是如此)。也就是说无参构造器的初始容量为10,而传进容量为0的初始容量为1。同时这也就是为什么会有
	        EMPTY_ELEMENTDATA和DEFAULTCAPACITY_EMPTY_ELEMENTDATA这两个常量的存在,虽然它们的值都是“{}”
	        原因就在于无参构造器和有参构造器完全就是两种不同的实现策略:如果你想要具体的初始容量,那么就调用有参构造器吧,
	        即使传入的是0也是符合这种情况的;而如果你不在乎初始的容量是多少,那么就调用无参构造器就行了,这会给你默
	        认为10的初始容量
       		*/
            minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
        }

		//如果调用的是有参构造器
        ensureExplicitCapacity(minCapacity);
    }


================ensureExplicitCapacity(minCapacity)代码如下==============

    private void ensureExplicitCapacity(int minCapacity) {
    	//修改次数+1(快速失败机制)
        modCount++;

        /*
	    如果+1后期望的容量比实际数组的容量还大,就需要扩容了(如果minCapacity也就是size+1后发生了数据溢出,
	    那么minCapacity就变为了一个负数,并且是一个接近int最小值的数。而此时的elementData.length也会是一个接近
	    int最大值的数,那么该if条件也有可能满足,此时会进入到grow方法中的hugeCapacity方法中抛出溢出错误)
        */
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }

================== grow(minCapacity); =================

    private void grow(int minCapacity) {
        // 获取扩容前的旧数组容量
        int oldCapacity = elementData.length;
        //扩容1.5倍
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        
    /*
    如果新数组容量比+1后期望的容量还要小,此时把新数组容量修正为+1后期望的容量(对应于newCapacity为0或1的情况)
    这里以及后面的判断使用的都是“if (a - b < 0)”形式,而不是常规的“if (a < b)”形式是有原因的,
    原因就在于需要考虑数据溢出的情况:如果执行了*1.5的扩容策略后newCapacity发生了数据溢出,那么它就一样
    变为了一个负数,并且是一个接近int最小值的数。而minCapacity此时也必定会是一个接近int最大值的数,
    那么此时的“newCapacity - minCapacity”计算出来的结果就可能会是一个大于0的数。于是这个if条件
    就不会执行,而是会在下个条件中的hugeCapacity方法中处理这种溢出的问题。这同上面的分析是类似的
    而如果这里用的是“if (newCapacity < minCapacity)”,数据溢出的时候该if条件会返回true,于是
    newCapacity会错误地赋值为minCapacity,而没有使用*1.5的扩容策略
     */
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
		
		/*
	    如果扩容后的新数组容量比设定好的容量最大值(Integer.MAX_VALUE - 8)还要大,就重新设置一下新数组容量的上限
	    同上面的分析,如果发生数据溢出的话,这里的if条件也可能是满足的,那么也会走进hugeCapacity方法中去处理
        */
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
            
        /*
	    可以看到这里是通过Arrays.copyOf(System.arraycopy)的方式来进行数组的拷贝,
	    容量是扩容后的新容量newCapacity,将拷贝后的新数组赋值给elementData即可
        */
        elementData = Arrays.copyOf(elementData, newCapacity);
    }


========================= hugeCapacity ==============
    private static int hugeCapacity(int minCapacity) {
    
    	//minCapacity对应于size+1,所以如果minCapacity<0就说明发生了数据溢出,就抛出错误
        if (minCapacity < 0) // overflow
            throw new OutOfMemoryError();

    /*
    如果minCapacity大于MAX_ARRAY_SIZE,就返回int的最大值,否则返回MAX_ARRAY_SIZE
    不管返回哪个,这都会将newCapacity重新修正为一个大于0的数,也就是处理了数据溢出的情况
    其实从这里可以看出:本方法中并没有使用*1.5的扩容策略,而只是设置了一个上限而已。但是在Java中
    真能申请得到Integer.MAX_VALUE这么大的数组空间吗?其实不见得,这只是一个理论值。实际上需要考虑
    -Xms和-Xmx等一系列JVM参数所设置的值。所以这也可能就是MAX_ARRAY_SIZE(Integer.MAX_VALUE - 8)
    其中-8的含义吧。但不管如何,当数组容量达到这么大的量级时,乘不乘1.5其实已经不太重要了)
     */
        return (minCapacity > MAX_ARRAY_SIZE) ?
            Integer.MAX_VALUE :
            MAX_ARRAY_SIZE;
    }

3.2 在指定位置添加元素

/**
 * ArrayList:
 */
public void add(int index, E element) {
    //index参数校验
    rangeCheckForAdd(index);
 
    //查看是否需要扩容
    ensureCapacityInternal(size + 1);
    /*
    这里数组拷贝的意义,就是将index位置处以及后面的数组元素往后移动一位,以此来挪出一个位置
    System.arraycopy是直接对内存进行复制,在大数据量下,比for循环更快
     */
    System.arraycopy(elementData, index, elementData, index + 1,
            size - index);
    //然后将需要插入的元素插入到上面挪出的index位置处就可以了
    elementData[index] = element;
    //最后size+1,代表添加了一个元素
    size++;
}
 
/**
 * 第6行代码处:
 * 检查传入的index索引位是否越界,如果越界就抛异常
 */
private void rangeCheckForAdd(int index) {
    if (index > size || index < 0)
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
 
private String outOfBoundsMsg(int index) {
    return "Index: " + index + ", Size: " + size;
}


4. get方法

/**
 * ArrayList:
 */
public E get(int index) {
    //index参数校验
    rangeCheck(index);
 
    //获取数据
    return elementData(index);
}
 
/**
 * 第6行代码处:
 * 这里只检查了index大于等于size的情况,而index为负数的情况
 * 在elementData方法中会直接抛出ArrayIndexOutOfBoundsException
 */
private void rangeCheck(int index) {
    if (index >= size)
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
 
/**
 * 第8行代码处:
 * 可以看到,这里是直接从elementData数组中获取指定index位置的数据
 */
@SuppressWarnings("unchecked")
E elementData(int index) {
    return (E) elementData[index];
}


5. remove方法

5.1 remove(Object o)删除指定的元素

/**
 * ArrayList:
 */
public boolean remove(Object o) {
    if (o == null) {
        //如果要删除的元素为null
        for (int index = 0; index < size; index++)
            //遍历数组中的每一个元素,找到第一个为null的元素
            if (elementData[index] == null) {
                /*
                删除这个元素,并返回true。这里也就是在做清理的工作:遇到一个为null的元素就清除掉
                注意这里只会清除一次,并不会全部清除
                 */
                fastRemove(index);
                return true;
            }
    } else {
        //如果要删除的元素不为null
        for (int index = 0; index < size; index++)
            //找到和要删除的元素是一致的数组元素
            if (o.equals(elementData[index])) {
                /*
                找到了一个就进行删除,并返回true。注意这里只会找到并删除一个元素,
                如果要删除所有的元素就调用removeAll方法即可
                 */
                fastRemove(index);
                return true;
            }
    }
    /*
    如果要删除的元素为null并且找不到为null的元素,或者要删除的元素不为null并且找不到和要删除元素相等的数组元素,
    就说明此时不需要删除元素,直接返回false就行了
     */
    return false;
}
 
/**
 * 第14行和第26行代码处:
 */
private void fastRemove(int index) {
    //修改次数+1
    modCount++;
    //numMoved记录的是移动元素的个数
    int numMoved = size - index - 1;
    if (numMoved > 0)
        /*
        这里数组拷贝的意义,就是将index+1位置处以及后面的数组元素往前移动一位,
        这会将index位置处的元素被覆盖,也就是做了删除
         */
        System.arraycopy(elementData, index + 1, elementData, index,
                numMoved);
    /*
    因为上面是左移了一位,所以最后一个位置相当于腾空了,这里也就是将最后一个位置(--size)置为null
    当然如果上面计算出来的numMoved本身就小于等于0,也就是index大于等于size-1的时候(大于不太可能,
    是属于异常的情况),意味着不需要进行左移。此时也将最后一个位置置为null就行了。置为null之后,
    原有数据的引用就会被断开,GC就可以工作了
     */
    elementData[--size] = null;
}

5.2 remove(int index)删除指定位置处的元素

/**
 * ArrayList:
 */
public E remove(int index) {
    //index参数校验
    rangeCheck(index);
 
    //修改次数+1
    modCount++;
    //获取指定index位置处的元素
    E oldValue = elementData(index);
 
    //numMoved记录的是移动元素的个数
    int numMoved = size - index - 1;
    if (numMoved > 0)
        /*
        同上面fastRemove方法中的解释,这里同样是将index+1位置处以及后面的数组元素往前移动一位,
        这会将index位置处的元素被覆盖,也就是做了删除(这里是否可以考虑封装?)
         */
        System.arraycopy(elementData, index + 1, elementData, index,
                numMoved);
    //同上,将最后一个位置(--size)置为null
    elementData[--size] = null;
 
    //删除之后,将旧值返回就行了
    return oldValue;
}


6. foreach时remove/add抛异常的原因!

这是《阿里巴巴编码规范》中的一条规定:不要在foreach循环里进行元素的remove/add操作。

6.1 remove操作

正例:

        List<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");

        Iterator<String> iterator = list.iterator();
        while (iterator.hasNext()) {
            String item = iterator.next();
            if ("2".equals(item)) {
                iterator.remove();
            }
        }

反例:

        List<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");

        for (String item : list) {
            if ("2".equals(item)) {
                list.remove(item);
            }
        }

        运行上面的代码可以看到,使用迭代器的删除操作是不会有问题、能成功删除的;而使用foreach循环进行删除则会抛出ConcurrentModificationException异常,但如果使用foreach循环删除第一个元素“1”的时候又会发现不会抛出异常。那么这到底是为什么呢?
        众所周知,foreach循环是一种语法糖,那么其背后到底是如何来实现的呢?将上面反例的代码反编译后的结果如下:javap -c -s Test.class

public class com.hys.util.Test {
  public com.hys.util.Test();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return
 
  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class java/util/ArrayList
       3: dup
       4: invokespecial #3                  // Method java/util/ArrayList."<init>":()V
       7: astore_1
       8: aload_1
       9: ldc           #4                  // String 1
      11: invokeinterface #5,  2            // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
      16: pop
      17: aload_1
      18: ldc           #6                  // String 2
      20: invokeinterface #5,  2            // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
      25: pop
      26: aload_1
      27: invokeinterface #7,  1            // InterfaceMethod java/util/List.iterator:()Ljava/util/Iterator;
      32: astore_2
      33: aload_2
      34: invokeinterface #8,  1            // InterfaceMethod java/util/Iterator.hasNext:()Z
      39: ifeq          72
      42: aload_2
      43: invokeinterface #9,  1            // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
      48: checkcast     #10                 // class java/lang/String
      51: astore_3
      52: ldc           #6                  // String 2
      54: aload_3
      55: invokevirtual #11                 // Method java/lang/String.equals:(Ljava/lang/Object;)Z
      58: ifeq          69
      61: aload_1
      62: aload_3
      63: invokeinterface #12,  2           // InterfaceMethod java/util/List.remove:(Ljava/lang/Object;)Z
      68: pop
      69: goto          33
      72: return
}

        可以看到第23行代码处、第26行代码处和第29行代码处后面的解释,也就是foreach循环是通过调用List.iterator方法来生成一个迭代器,通过Iterator.hasNext方法和Iterator.next方法来实现的遍历操作,也就是说foreach其实也是调用iterator来进行遍历的!!,但为什么两者删除效果不同呢?iterator.remove正确,而 list.remove(item);却有问题呢?其实是remove方法不同,往下看吧!

既然两者都使用iterator实现的遍历,那么就来看下ArrayList中iterator方法的实现:

public Iterator<E> iterator() {
    return new Itr();
}

可以看到返回了一个内部类Itr;

/**
 * An optimized version of AbstractList.Itr
 */
private class Itr implements Iterator<E> {
    int cursor;       // cursor代表下一次的索引位置
    int lastRet = -1; // lastRet代表上一次的索引位置 
    int expectedModCount = modCount;
 
    Itr() {}
 
    public boolean hasNext() {
        return cursor != size;
    }
 
    @SuppressWarnings("unchecked")
    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];
    }
 
    //...
}

而抛出异常是在上面第17行代码处的checkForComodification方法里面抛出的,下面来看一下它的实现:

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

        可以看到如果modCountexpectedModCount不等就会抛出ConcurrentModificationException异常。而上面说过,在add方法和remove方法中,会对modCount修改标志位做+1的操作。这里的modCount是为了做快速失败用的。快速失败指的是如果在遇到并发修改时,迭代器会快速地抛出异常,而不是在将来某个不确定的时间点冒着任意而又不确定行为的风险来进行操作,也就是将可能出现的bug点推前。在包括HashMap在内的绝大部分集合类都是有快速失败机制的。注意:这里的并发修改指的并不都是发生在并发时的修改,也有可能是在单线程中所做的修改导致的,就如同上面的反例一样。

反例:

        List<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");

        for (String item : list) {
            if ("2".equals(item)) {
                list.remove(item);
            }
        }

        这里拿上面的反例来举例(foreach在运行时的遍历操作其实也是走的iterator),ArrayList调用了两次add方法,也就是此时的modCount应该为2。而expectedModCount如上所示(int expectedModCount = modCount);,一开始会初始化为modCount的值,也等于2。

接下来看一下循环 remove 操作:

第一次循环:

        在iterator的next()方法中,因为此时的modCount和expectedModCount都为2,所以第一次循环中不会抛出异常,抛出异常都是发生在不是第一次循环的情况中。在next方法走完后,foreach循环方法体中的remove方法的if条件判断不满足,就结束了本次循环。

第二次循环:

        因为此时的modCountexpectedModCount都还没变,所以第二次循环的hasNext和next方法都是能成功走完的。注意:接下来进入foreach循环方法体中的remove方法中,进行删除元素。而此时的size变为了size-1=1。上面分析过,在remove方法中,会对modCount+1,也就变为了3。remove方法正常删除

第三次循环:

        然后会走入到第三次循环中的hasNext方法中。

    public boolean hasNext() {
    	//如果下一次的索引位置不等于数组的元素个数,就返回true
        return cursor != size;
    }

因为此时的size由于remove操作已经变为了1,问题就在这里!此时的cursor为2(cursor代表下一次的索引位置),所以两者不等,返回了true。所以接下来会继续走入到next方法中的checkForComodification方法中,判断此时的modCount和expectedModCount是否相等。因为此时的modCount已经变为了3,和expectedModCount的值为2不等,所以在此抛出了ConcurrentModificationException异常。这就是foreach时remove会出现异常的原因!!

再来看一下删除“1”的时候为什么不会抛出异常:

第一次循环:

同上,此时的modCount和expectedModCount都为2,所以第一次循环中的hasNext和next方法都不会抛异常。在这之后会进入到foreach循环方法体中的remove方法中,进行删除元素。同上,size-1变为了1,而modCount+1变为了3。

第二次循环:

在第二次循环的hasNext方法中,此时的cursor为1,而size也是1,两者相等。所以hasNext方法返回false,就跳出了foreach循环,不会走到随后的next方法中,也就不会抛出异常。

由此得出结论:在使用foreach进行remove操作时,只有删除集合长度length的前一个数时才不会出现并发修改异常!

       List<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");
        list.add("3");
        list.add("4");
		// 会出现ConcurrentModificationException异常
        for (String item : list) {
            System.out.println(item);
            if ("1".equals(item)) {
                list.remove(item);
            }
        }

		// 不会出现ConcurrentModificationException异常
        for (String item : list) {
            System.out.println(item);
            if ("3".equals(item)) {
                list.remove(item);
            }
        }

注意:如下这种,就算不加 if ("xxx".equals(xxx))条件直接删除的,也会出现ConcurrentModificationException,原因和上文一样的!

        List<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");
        list.add("3");

        for (String item : list) {
                list.remove(item);
        }

结果:
在这里插入图片描述
而使用 for (int i = 0; i <4; i++)的形式却不受影响

      list.add("1");
        list.add("2");
        list.add("3");
        list.add("4");

        for (int i = 0; i <4; i++) {
            String s = list.get(0);
            list.remove(s);
        }
        
        //删除之后的集合为:[]
         System.out.println("删除之后的集合为:"+list.toString());

 

6.2 foreach中进行add操作同样触发并发修改异常

        List<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");
        list.add("3");

        for (String item : list) {
            if ("1".equals(item)) {
                list.add(item);
            }
        }

结果:
在这里插入图片描述

原因:

  • 第一次循环:
    • 同上,此时的modCountexpectedModCount都为2,所以第一次循环中的hasNext和next方法都不会抛异常。在这之后会进入到foreach循环方法体中的add方法中,进行添加元素。size+1变为了3,而modCount+1也变为了3。
  • 第二次循环:
    • 在第二次循环的hasNext方法中,此时的cursor为1,而size为3,两者不等,所以hasNext方法返回true,会走到随后的next方法中。而在next方法中的checkForComodification方法中,此时的modCount已经变为了3,而expectedModCount还是为2。两者不等,所以在此抛出了ConcurrentModificationException异常。

从上面的分析可以看出,只要在foreach循环方法体中有进行修改过modCount和size的操作,就都有可能会是抛出异常的


7. 为什么使用iterator迭代器remove/add元素,不会抛异常?

7.1 remove操作

        既然现在已经知道了foreach循环中使用remove/add操作抛出异常的原因,那么就可以分析一下为什么使用迭代器进行相关操作就不会有问题呢?

        List<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");

        Iterator<String> iterator = list.iterator();
        //使用 iterator 执行删除就不会出问题!
        while (iterator.hasNext()) {
            String item = iterator.next();
            if ("2".equals(item)) {
                iterator.remove();
            }
        }

        上面正例代码中的list.iterator()、iterator.hasNext()、iterator.next()方法都是跟foreach循环里的实现是一样的,而区别在于iterator.remove()操作。这里的remove不是ArrayList中的remove操作,而是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();
    }
}

        可以看到Itr内部类中的remove方法其实还是调用了ArrayListremove操作进行删除的,但同时注意expectedModCount = modCount会将expectedModCount更新为此时modCount的最新值,这样在next方法中就不会抛出异常了;cursor = lastRet会将cursor更新为lastRet(lastRet代表上一次的索引位置),即将cursor-1(因为此时要remove,所以cursor指针需要减一)。这样在hasNext方法中就会返回正确的值了。总结一句话就是:iterator.remove()list.remove()的基础上又进行了expectedModCountcursor的修正,使其到当前删除后的状态,让下一次遍历执行到Iterator hasNext()next()方法时都返回正常结果,不会抛出并发修改异常,聪明!

7.2 add操作

        虽然iterator方法可以提供remove操作来使删除能正确执行,但其却没有提供相关add方法的API。无妨,ArrayList中为我们提供了listIterator方法,其中就有add操作(如果一定要用迭代器方式来实现的话)。相关的示例代码如下:

	List<String> list = new ArrayList<>();
	list.add("1");
	list.add("2");
	 
	ListIterator<String> iterator = list.listIterator();
	while (iterator.hasNext()) {
	    String item = iterator.next();
	    if ("1".equals(item)) {
	        iterator.add(item);
	    }
	}

同上,首先来看一下第5行代码处的listIterator方法:

	public ListIterator<E> listIterator() {
	    return new ListItr(0);
	}

listIterator方法返回了一个ListItr内部类。那么就来看一下ListItr的代码实现:

private class ListItr extends Itr implements ListIterator<E> {
    ListItr(int index) {
        super();
        cursor = index;
    }
 
    //...
 
    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();
        }
    }
}

可以看到ListItr内部类是继承了Itr类,包括hasNextnext等方法都是直接复用的。而在add方法中的也是调用了ArrayList的add操作进行添加的。另外和Itr的remove方法一样,第17行代码处也是在更新expectedModCount为此时modCount的最新值,第15行代码处的cursor更新为+1后的结果(因为此时是在做add操作)。这样后续的hasNext和next操作就不会有问题了。

最后

以上就是传统吐司为你收集整理的ArrayList为什么线程不安全?说说foreach与iterator时remove的区别的全部内容,希望文章能够帮你解决ArrayList为什么线程不安全?说说foreach与iterator时remove的区别所遇到的程序开发问题。

如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(76)

评论列表共有 0 条评论

立即
投稿
返回
顶部