并发编程进阶03-java.util.ConcurrentModificationException异常-多线程情况

版权声明:博观而约取,厚积而薄发。 https://blog.csdn.net/BruceLiu_code/article/details/88379563

  同步类容器都是线程安全的,但是在某些场景下可能需要加锁来保护复合操作。复合操作如:迭代(反复访问元素,遍历容器中所有的元素)、跳转(根据指定的顺序找到当前元素的下一个元素)、以及条件运算。这个复合操作在多线程并发地修改容器的时候,可能表现出意外的行为,最为经典的便是ConcurrentModifationException,原因是当容器迭代的过程中,被并发地修改了容器的内容,这是由于在早起迭代器设计的时候并没有考虑并发修改的问题。

1. 多线程情况下ConcurrentModifationException异常

1.1 问题再现
/**
 * 多线程情况下ConcurrentModifationException异常
 * @author bruceliu
 * @create 2019-03-10 15:58
 */
public class Test3 {

    public static void main(String[] args) {

        final ArrayList<Integer> arrayList=new ArrayList<Integer>();
        for (int i = 0; i <20 ; i++) {
            arrayList.add(Integer.valueOf(i));
        }

        //这个线程在读ArrayList中的数据
        Thread thread1=new Thread(new Runnable() {
            public void run() {
                Iterator<Integer> iterator = arrayList.iterator();
                while(iterator.hasNext()){
                    System.out.println("Thread1 "+iterator.next().intValue());
                }
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        //这个线程在移除ArrayList中的数据
        Thread thread2 = new Thread(new Runnable() {
            public void run() {
                Iterator<Integer> iterator = arrayList.iterator();
                while (iterator.hasNext()) {
                    System.out.println("thread2 " + iterator.next().intValue());
                    iterator.remove();
                }
            }
        });

        thread1.start();
        thread2.start();

    }
}

在个测试代码中,开启两个线程,一个线程遍历,另外一个线程遍历加修改。程序输出结果如下

Thread1 0
thread2 1
thread2 2
thread2 3
thread2 4
thread2 5
thread2 6
thread2 7
thread2 8
thread2 9
thread2 10
thread2 11
thread2 12
thread2 13
thread2 14
thread2 15
thread2 16
thread2 17
thread2 18
thread2 19
java.util.ConcurrentModificationException
	at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:901)
	at java.util.ArrayList$Itr.next(ArrayList.java:851)
	at com.bruceliu.demo17.Test3$1.run(Test3.java:26)
	at java.lang.Thread.run(Thread.java:745)

两个thread都是使用的同一个arrayList,thread2修改完后modCount = 21,此时thread2的expectedModCount = 21 可以一直遍历到结束;thread1的expectedModCount仍然为20,因为thread1的expectedModCount只是在初始化的时候赋值,其后并未被修改过。因此当arrayList的modCount被thread2修改为21之后,thread1想继续遍历必定会抛出异常了。
在这个示例代码里面,两个thread,每个thread都有自己的iterator,当thread2通过iterator方法修改expectedModCount必定不会被thread1感知到。这个跟ArrayList非线程安全是无关的,即使这里面的ArrayList换成Vector也是一样的结果,不信上测试代码:

/**
 * 多线程情况下ConcurrentModifationException异常
 * @author bruceliu
 * @create 2019-03-10 15:58
 */
public class Test3 {

    public static void main(String[] args) {

        final Vector<Integer> arrayList=new Vector<Integer>();
        for (int i = 0; i <20 ; i++) {
            arrayList.add(Integer.valueOf(i));
        }

        //这个线程在读Vector中的数据
        Thread thread1=new Thread(new Runnable() {
            public void run() {
                Iterator<Integer> iterator = arrayList.iterator();
                while(iterator.hasNext()){
                    System.out.println("Thread1 "+iterator.next().intValue());
                }
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        //这个线程在移除Vector中的数据
        Thread thread2 = new Thread(new Runnable() {
            public void run() {
                Iterator<Integer> iterator = arrayList.iterator();
                while (iterator.hasNext()) {
                    System.out.println("thread2 " + iterator.next().intValue());
                    iterator.remove();
                }
            }
        });

        thread1.start();
        thread2.start();

    }
}

运行结果:

Exception in thread "Thread-0" java.util.ConcurrentModificationException
	at java.util.Vector$Itr.checkForComodification(Vector.java:1184)
	at java.util.Vector$Itr.next(Vector.java:1137)
	at com.bruceliu.demo17.Test3$1.run(Test3.java:27)
	at java.lang.Thread.run(Thread.java:745)
Thread1 0
Thread1 1
Thread1 2
Thread1 3
Thread1 4
thread2 0
Thread1 5
thread2 1
thread2 2
thread2 3
thread2 4
thread2 5
thread2 6
thread2 7
thread2 8
thread2 9
thread2 10
thread2 11
thread2 12
thread2 13
thread2 14
thread2 15
thread2 16
thread2 17
thread2 18
thread2 19
1.2 多线程下解决方案
  • 方案一:iterator遍历过程加同步锁,锁住整个arrayList
/**
 * 多线程情况下ConcurrentModifationException异常
 *
 * @author bruceliu
 * @create 2019-03-10 15:58
 */
public class Test3 {

    public static void main(String[] args) {

        final ArrayList<Integer> arrayList = new ArrayList<Integer>();
        for (int i = 0; i < 20; i++) {
            arrayList.add(Integer.valueOf(i));
        }

        //这个线程在读Vector中的数据
        Thread thread1 = new Thread(new Runnable() {
            public void run() {
                synchronized (arrayList) {
                    Iterator<Integer> iterator = arrayList.iterator();
                    while (iterator.hasNext()) {
                        System.out.println("Thread1 " + iterator.next().intValue());
                    }
                    try {
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        });

        //这个线程在移除Vector中的数据
        Thread thread2 = new Thread(new Runnable() {
            public void run() {
                synchronized (arrayList) {
                    Iterator<Integer> iterator = arrayList.iterator();
                    while (iterator.hasNext()) {
                        System.out.println("thread2 " + iterator.next().intValue());
                        iterator.remove();
                    }
                }
            }
        });

        thread1.start();
        thread2.start();

    }
}

这种方案本质上是将多线程通过加锁来转变为单线程操作,确保同一时间内只有一个线程去使用iterator遍历arrayList,其它线程等待,效率显然是只有单线程的效率。

  • 方案二:使用CopyOnWriteArrayList
/**
 * 多线程情况下ConcurrentModifationException异常
 *
 * @author bruceliu
 * @create 2019-03-10 15:58
 */
public class Test3 {

    public static void main(String[] args) {

        final List<Integer> list = new CopyOnWriteArrayList<Integer>();

        for (int i = 0; i < 20; i++) {
            list.add(Integer.valueOf(i));
        }

        Thread thread1 = new Thread(new Runnable() {
            public void run() {
                ListIterator<Integer> iterator = list.listIterator();
                while (iterator.hasNext()) {
                    System.out.println("thread1 " + iterator.next().intValue());
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        });

        Thread thread2 = new Thread(new Runnable() {
            public void run() {
                for (Integer integer : list) {
                    System.out.println("thread2 " + integer.intValue());
                    if (integer.intValue() == 5) {
                        list.remove(integer);
                    }
                }
                for (Integer integer : list) {
                    System.out.println("thread2 again " + integer.intValue());
                }
//                ListIterator<Integer> iterator = list.listIterator();
//                while (iterator.hasNext()) {
//                    Integer integer = iterator.next();
//                    System.out.println("thread2 " + integer.intValue());
//                    if (integer.intValue() == 5) {
//                        iterator.remove();
//                    }
//                }
            }
        });
        thread1.start();
        thread2.start();
    }
}

运行结果:

thread1 0
thread2 0
thread2 1
thread2 2
thread2 3
thread2 4
thread2 5
thread2 6

thread2 7
thread2 8
thread2 9
thread2 10
thread2 11
thread2 12
thread2 13
thread2 14
thread2 15
thread2 16
thread2 17
thread2 18
thread2 19
thread2 again 0
thread2 again 1
thread2 again 2
thread2 again 3
thread2 again 4
thread2 again 6

thread2 again 7
thread2 again 8
thread2 again 9
thread2 again 10
thread2 again 11
thread2 again 12
thread2 again 13
thread2 again 14
thread2 again 15
thread2 again 16
thread2 again 17
thread2 again 18
thread2 again 19
thread1 1
thread1 2
thread1 3
thread1 4
thread1 5
thread1 6

thread1 7
thread1 8
thread1 9
thread1 10
thread1 11
thread1 12
thread1 13
thread1 14
thread1 15
thread1 16
thread1 17
thread1 18
thread1 19

我们先分析thread2的输出结果,第一次遍历将4 5 6都输出,情理之中;第一次遍历后删除掉了一个元素,第二次遍历输出4 6,符合我们的预期。
再来看下thread1的输出结果,有意思的事情来了,thread1 仍然输出了4 5 6,什么鬼?thread1和thread2都是遍历list,list在thread1遍历第二个元素的时候就已经删除了一个元素了,为啥还能输出5?
为了了解这个问题,需要了解CopyOnWriteArrayList是如何做到一边遍历的同时还能一边修改并且还不抛异常的。
在这里不想再深入分析CopyOnWriteArrayList代码,后续会专门出一篇博客来解释这个类的源码的。
这里说一下CopyOnWriteArrayList的解决思路,其实很简单:

private transient volatile Object[] array;

CopyOnWriteArrayList本质上是对array数组的一个封装,一旦CopyOnWriteArrayList对象发生任何的修改都会new一个新的Object[]数组newElement,在newElement数组上执行修改操作,修改完成后将newElement赋值给array数组(array=newElement)。

因为array是volatile的,因此它的修改对所有线程都可见。

了解了CopyOnWriteArrayList的实现思路之后,我们再来分析上面代码test6为什么会出现那样的输出结果。先来看下thread1和thread2中用到的两种遍历方式的源码:

public void forEach(Consumer<? super E> action) {
        if (action == null) throw new NullPointerException();
        // 在遍历开始前获取当前数组
        Object[] elements = getArray();
        int len = elements.length;
        for (int i = 0; i < len; ++i) {
            @SuppressWarnings("unchecked") E e = (E) elements[i];
            action.accept(e);
        }
    }
public ListIterator<E> listIterator() {
        return new COWIterator<E>(getArray(), 0);
    }
    static final class COWIterator<E> implements ListIterator<E> {
        /** Snapshot of the array */
        private final Object[] snapshot;
        /** Index of element to be returned by subsequent call to next.  */
        private int cursor;

        private COWIterator(Object[] elements, int initialCursor) {
            cursor = initialCursor;
            // 初始化为当前数组
            snapshot = elements;
        }

        public void remove() {
            // 已经不支持Iterator remove操作了!!
            throw new UnsupportedOperationException();
        }

        public boolean hasNext() {
            return cursor < snapshot.length;
        }

        @SuppressWarnings("unchecked")
        public E next() {
            if (! hasNext())
                throw new NoSuchElementException();
            return (E) snapshot[cursor++];
        }

        // 此处省略其他无关代码
    }

有了这个时间节点表就很清楚了,thread1和thread2 start的时候都会将A数组初始化给自己的临时变量,之后遍历的也都是这个A数组,而不管CopyOnWriteArrayList中的array发生了什么变化。因此也就解释了thread1在thread2 remove掉一个元素之后为什么还会输出5了。在thread2中,第二次遍历初始化数组变成了当前的array,也就是修改后的B,因此不会有Integer.valueOf(5)这个元素了。

从test6执行结果来看,CopyOnWriteArrayList确实能解决一边遍历一边修改并且还不会抛异常,但是这也是有代价的:

(1) thread2对array数组的修改thread1并不能被动感知到,只能通过hashCode()方法去主动感知,否则就会一直使用修改前的数据

(2) 每次修改都需要重新new一个数组,并且将array数组数据拷贝到new出来的数组中,效率会大幅下降

此外CopyOnWriteArrayList中的ListIterator实现是不支持remove、add和set操作的,一旦调用就会抛出UnsupportedOperationException异常,因此test6注释代码行中如果运行是会抛异常的。

猜你喜欢

转载自blog.csdn.net/BruceLiu_code/article/details/88379563