• 由浅入深——从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在版本控制上也使用了这样的乐观锁技术。

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

  • 相关阅读:
    Java集合 使用Map
    Java集合 编写equals方法
    yiyou本地安装出现版本问题
    网站地图制作
    SEO小爬虫工具文章排版
    知名企业招聘技术员题库
    测试上网速度
    邮件传输协议软件
    JSONP跨域问题
    织梦搬家
  • 原文地址:https://www.cnblogs.com/superlsj/p/11655523.html
Copyright © 2020-2023  润新知