zoukankan      html  css  js  c++  java
  • 由浅入深——从ArrayList浅谈并发容器

    原创作品转载请附:https://www.cnblogs.com/superlsj/p/11655523.html

    一、一个案例引发的思考

    public class ArrayListTest {
        public static void main(String[] args) {
            List<String> list = new ArrayList<String>();
            for (int i = 1; i <= 50; i++) {
                new Thread(() -> {
                    list.add(UUID.randomUUID().toString().substring(0,8));
                    System.out.println(list);
                }).start();
            }
        }
    }
    java.util.ConcurrentModificationException
        at java.util.ArrayList$Itr.checkForComodification(Unknown Source)
        at java.util.ArrayList$Itr.next(Unknown Source)
        at java.util.AbstractCollection.toString(Unknown Source)
        at java.lang.String.valueOf(Unknown Source)
        at java.io.PrintStream.println(Unknown Source)
        at com.qlu.test1.ArrayListTest.lambda$0(ArrayListTest.java:13)
        at java.lang.Thread.run(Unknown Source)

      即所谓的并发修改异常。我们先来分析一下为什么会报这个错。

    二、错误产生的原因

      我们知道,ArrayList是线程不安全的,它的所有方法没有加Synchronized锁:例如

    public boolean add(E e) {
            ensureCapacityInternal(size + 1);  // Increments modCount!!
            elementData[size++] = e;
            return true;
    }

      也就是说,上面定义的50个线程都会抢占此ArrayList。那么为什么会爆出错误呢?当我把System.out.println(list);删除后,错误就没报了。那么问题可能出在ArrayList的toString()方法。查看源码会发现

    ArrayList类并没有toString()方法。这个toString方法是从ArrayList的父类的父类:AbstractCollection类继承而来的,toString()方法源码如下:

    public String toString() {
            Iterator<E> it = iterator();
            if (! it.hasNext())
                return "[]";
    
            StringBuilder sb = new StringBuilder();
            sb.append('[');
            for (;;) {
                E e = it.next();
                sb.append(e == this ? "(this Collection)" : e);
                if (! it.hasNext())
                    return sb.append(']').toString();
                sb.append(',').append(' ');
            }
        }

      toString()方法遍历集合所有的元素拼接了一个字符串返回。那么问题出在哪呢?首先记住两个变量:modCount和expectedModCount。

      由上面的add方法的源码可以看到,在方法体内的先执行了ensureCapacityInternal(size + 1);方法,这个方法中调用了ensureExplicitCapacity()方法,而在此方法的第一行:赫然写着modCount++

    private void ensureExplicitCapacity(int minCapacity) {
            modCount++;
    
            // overflow-conscious code
            if (minCapacity - elementData.length > 0)
                grow(minCapacity);
        }

      也就是说集合的每一次添加操作都会触发modCount++操作,而并没有expectedModCount++,在来看一下expectedModCount是何时被赋值的:

    在集合完成创建后,expectedModCount的值是0,在创建迭代器时将modCount的值赋给了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;
    
            public boolean hasNext() {
                return cursor != size;
            }    
    ......

      而在迭代器创建以后,expectedModCount的值就不再改变。也就是说此后的add操作会改变modCount,而不会改变expectedModCount。那么重点来了。在使用迭代器的next()方法时,会调用checkForComodification()方法验证expectedModCount和modCount是否相等:

    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];
            }

      checkForComodification()方法源码如下:如果 modCount != expectedModCount 不相等,就抛出并发修改异常。

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

      在回到开头的案例:

    for (int i = 1; i <= 50; i++) {
                new Thread(() -> {
                    list.add(UUID.randomUUID().toString().substring(0,8));
                    System.out.println(list);
                }).start();
            }

      由于list是共享资源,即所有线程共享一个modCount资源。假设A线程添加一个UUID后,由于需要输出list,而输出list需要创建迭代器,此时他根据集合的modCount假设为2,那么expectedModCount也是2,但是A线程next()迭代集合拼接字符串的操作未完成,CPU就将资源转给了其他线程,假设转给了线程B,B拿到资源后由于进行了add操作,所以list的modCount++;假设modCount++后为3,虽然B线程创建迭代器时会根据最新的modCount给expectedModCount=3,但是如果此时CPU又将资源转给了线程A,线程A加载自己原先的上下文,或得上一次执行时的迭代器对象,而此迭代器对象持有的 expectedModCount为2,而共享资源里的modCount却被B线程更新到了3,此时如果A线程继续迭代next(),就会发现modCount != expectedModCount 不相等,就抛出并发修改异常。

    三、如何解决问题

      1、将ArrayList改成Vector,不再赘述。

      2、使用集合工具类Collections

    public class ArrayListTest{
        public static void main(String[] args) {
            List<String> list = Collections.synchronizedList(new ArrayList<>());
            for (int i = 1; i <= 50; i++) {
                new Thread(() -> {
                    list.add(UUID.randomUUID().toString().substring(0,8));
                    System.out.println(list);
                }).start();
            }
        }
    }

      此方法同样可以用于其他线程不安全的集合类,例如:set、map

      3、使用并发容器【推荐使用】

    public class ArrayListTest{
        public static void main(String[] args) {
            List<String> list = new CopyOnWriteArrayList<>();
            for (int i = 1; i <= 50; i++) {
                new Thread(() -> {
                    list.add(UUID.randomUUID().toString().substring(0,8));
                    System.out.println(list);
                }).start();
            }
        }
    }

      将ArrayList换成CopyOnWriteArrayList问题就解决了,CopyOnWriteArrayList是怎么解决问题的呢?这就要提到一个重要的技术:写时复制技术。

    四、写时复制技术

      ArrayList和CopyOnWriteArrayList,都是集合,都是通过add增加元素,那么区别到底在哪里呢?先来看看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);
                newElements[len] = e;
                setArray(newElements);
                return true;
            } finally {
                lock.unlock();
            }
        }

      与Vector不同的是:CopyOnWriteArrayList并没有采用传统的synchronized,而是采用了ReentrantLock可重入锁。关于锁的只是本章节不做讨论,这里主要研究写时复制技术是如何实现的。add方法先创建了一个Object类型的数组,指向了旧数组,然后定义变量len保存数组容量,由于此数组从length从0开始,每增加一个元素,扩容一单位,所以size=length。然后定义新数组newElements,长度为len+1,在给新数组的新增位置添加add方法传进来的新元素后,调用setArray方法将旧数组的引用指向这个新数组。

      整个过程就像墙上贴着登记信息,传统的ArrayList的解决方法是:谁抢到登记表谁填信息,如果谁抢到后还没写完就被别人抢去了,那旧出现了数据不安全的问题。CopyOnWriteArrayList的解决方案就像是,第一个登记的将墙上的表格赋值一份到自己的线程私有的空间,等自己写完后就贴回墙上覆盖原来的表,内存是消耗了一点,但是绝对安全。在这个过程中还涉及了一个版本号的问题,假设此时两名名同学同时将墙上的空表(假设版本为1.0)复制一份自己填写姓名,但是张三明显会比诸葛孔明写得快,于是率先将自己的表贴到墙上覆盖了原表,并将表格版本提升到了2.0,而此时诸葛孔明写完了准备提交,JVM会校验版本信息,发现诸葛孔明不是基于最新版本的数据做的修改,所以修改无效,此时诸葛孔明同学就需要重新复制一份填写姓名。

      这样的思想在软件设计时非常常见,Git在版本控制上也使用了这样的乐观锁技术。

    附:新兵蛋子,如有错误,还请各位大哥指正。

  • 相关阅读:
    RUST实践.md
    redis.md
    opencvrust.md
    aws rds can't connect to mysql server on 'xx'
    Foundation ActionScript 3.0 With Flash CS3 And Flex
    Foundation Flash Applications for Mobile Devices
    Flash Mobile Developing Android and iOS Applications
    Flash Game Development by Example
    Actionscript 3.0 迁移指南
    在SWT中非UI线程控制界面
  • 原文地址:https://www.cnblogs.com/superlsj/p/11655523.html
Copyright © 2011-2022 走看看