• Java 集合:(番外篇一) ArrayList线程不安全性


    一、ArrayList 线程不安全性

      ArrayList 线程不安全案例:

     1 public class TestArrayList {
     2     private static List<Integer> list = new ArrayList<>();
     3 
     4     public static void main(String[] args) {
     5         for (int i = 0; i < 10; i++) {
     6             testList();
     7             list.clear();
     8         }
     9     }
    10 
    11     private static void testList() throws InterruptedException {
    12         Runnable runnable = () -> {
    13             for (int i = 0; i < 10000; i++) {
    14                 list.add(i);
    15             }
    16         };
    17 
    18         Thread t1 = new Thread(runnable);
    19         Thread t2 = new Thread(runnable);
    20         Thread t3 = new Thread(runnable);
    21 
    22         t1.start();
    23         t2.start();
    24         t3.start();
    25 
    26         t1.join();
    27         t2.join();
    28         t3.join();
    29 
    30         System.out.println(list.size());
    31     }
    32 }

      运行结果:这是它的输出结果,我们期望的结果应该都是:30000,然后并不是,这就是传说中的多线程并发问题了。

      

    二、可能导致的问题

      从以上结果可以总结出 ArrayList 在并发情况下会出现的几种现象。

      1、发生 ArrayIndexOutOfBoundsException 异常

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

       通过源码可以看出:ArrayList的实现主要就是用了一个Object的数组,用来保存所有的元素,以及一个size变量用来保存当前数组中已经添加了多少元素。

      执行add方法时,主要分为两步:

      •  首先判断elementData数组容量是否满足需求——》判断如果将当前的新元素加到列表后面,列表的elementData数组的大小是否满足,如果size + 1的这个需求长度大于了elementData这个数组的长度,那么就要对这个数组进行扩容;
      •  之后在elementData对应位置上设置元素的值。

      由于ArrayList添加元素是如上面分两步进行,可以看出第一个不安全的隐患,在多个线程进行add操作时可能会导致elementData数组越界。
      具体逻辑如下:

    1.列表大小为9,即size=9
    2.线程A开始进入add方法,这时它获取到size的值为9,调用ensureCapacityInternal方法进行容量判断。
    3.线程B此时也进入add方法,它获取到size的值也为9,也开始调用ensureCapacityInternal方法。
    4.线程A发现需求大小为10,而elementData的大小就为10,可以容纳。于是它不再扩容,返回。
    5.线程B也发现需求大小为10,也可以容纳,返回。
    6.线程A开始进行设置值操作, elementData[size++] = e 操作。此时size变为10。
    7.线程B也开始进行设置值操作,它尝试设置elementData[10] = e,而elementData没有进行过扩容,它的下标最大为9。于是此时会报出一个数组越界的异常ArrayIndexOutOfBoundsException.

    定位到异常所在源代码,毫无疑问,问题是出现在多线程并发访问下,由于没有同步锁的保护,造成了 ArrayList 扩容不一致的问题。

      2、程序正常运行,输出了少于实际容量的大小;

    这个也是多线程并发赋值时,对同一个数组索引位置进行了赋值,所以出现少于预期大小的情况。

      3、程序正常运行,输出了预期容量的大小;

    这是正常运行结果,未发生多线程安全问题,但这是不确定性的,不是每次都会达到正常预期的。

      4、元素值覆盖和为空问题

    elementData[size++] = e 设置值的操作同样会导致线程不安全。从这儿可以看出,这步操作也不是一个原子操作,它由如下两步操作构成:

    1 elementData[size] = e;
    2 size = size + 1;

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

    1.列表大小为0,即size=0
    2.线程A开始添加一个元素,值为A。此时它执行第一条操作,将A放在了elementData下标为0的位置上。
    3.接着线程B刚好也要开始添加一个值为B的元素,且走到了第一步操作。此时线程B获取到size的值依然为0,于是它将B也放在了elementData下标为0的位置上。
    4.线程A开始将size的值增加为1
    5.线程B开始将size的值增加为2

    这样线程AB执行完毕后,理想中情况为size为2,elementData下标0的位置为A,下标1的位置为B。而实际情况变成了size为2,elementData下标为0的位置变成了B,下标1的位置上什么都没有。并且后续除非使用set方法修改此位置的值,否则将一直为null,因为size为2,添加元素时会从下标为2的位置上开始。

      案例:

     1 import java.util.ArrayList;
     2 import java.util.List;
     3 
     4 public class ArrayListSafeTest {
     5 
     6     public static void main(String[] args) throws InterruptedException {
     7 
     8         final List<Integer> list = new ArrayList<Integer>();
     9         // 线程A将1-1000添加到列表
    10         new Thread(new Runnable() {
    11 
    12             @Override
    13             public void run() {
    14                 for (int i = 1; i < 1000; i++) {
    15                     list.add(i);
    16 
    17                     try {
    18                         Thread.sleep(1);
    19                     } catch (InterruptedException e) {
    20                         e.printStackTrace();
    21                     }
    22                 }
    23 
    24             }
    25 
    26         }).start();
    27         
    28         // 线程B将1001-2000添加到列表
    29         new Thread(new Runnable() {
    30 
    31             @Override
    32             public void run() {
    33                 for (int i = 1001; i < 2000; i++) {
    34                     list.add(i);
    35 
    36                     try {
    37                         Thread.sleep(1); 
    38                     } catch (InterruptedException e) {
    39                         e.printStackTrace();
    40                     }
    41                 }
    42 
    43             }
    44 
    45         }).start();
    46         
    47         Thread.sleep(1000);
    48 
    49         // 打印所有结果
    50         for (int i = 0; i < list.size(); i++) {
    51             System.out.println("第" + (i + 1) + "个元素为:" + list.get(i));
    52         }
    53     }
    54 }

    执行过程中,两种情况出现如下:

      

       

      5、

    三、解决方案

        那么在高并发情况下,使用什么样的列表集合保护线程安全呢?

        另外,像 HashMap, HashSet 等都有类似多线程安全问题,在多线程并发环境下避免使用这种集合。

    四、线程安全的 List 

       既然 ArrayList 是线程不安全的,怎么保证它的线程安全性呢?或者有什么替代方案?

      1、Vector

        使用Vector,但是这种效率也是比较慢的,会对整个方法加 synchronized,效率比较低。

      2、java.util.Collections.SynchronizedList

        java.util.Collections.SynchronizedList

        它能把所有 List 接口的实现类转换成线程安全的List,比 Vector 有更好的扩展性和兼容性。 

        SynchronizedList的构造方法如下:

    1 final List<E> list;
    2 
    3 SynchronizedList(List<E> list) {
    4     super(list);
    5     this.list = list;
    6 }

        SynchronizedList的部分方法源码如下:

     1 public E get(int index) {
     2     synchronized (mutex) {return list.get(index);}
     3 }
     4 public E set(int index, E element) {
     5     synchronized (mutex) {return list.set(index, element);}
     6 }
     7 public void add(int index, E element) {
     8     synchronized (mutex) {list.add(index, element);}
     9 }
    10 public E remove(int index) {
    11     synchronized (mutex) {return list.remove(index);}
    12 }

        很可惜,它所有方法都是带同步对象锁的,和 Vector 一样,它不是性能最优的。

        比如在读多写少的情况,SynchronizedList这种集合性能非常差,还有没有更合适的方案?

      3、java.util.concurrent.CopyOnWriteArrayList&java.util.concurrent.CopyOnWriteArraySet

        介绍两个并发包里面的并发集合类:

    java.util.concurrent.CopyOnWriteArrayList
    java.util.concurrent.CopyOnWriteArraySet

        CopyOnWrite集合类也就这两个,Java 1.5 开始加入,你要能说得上这两个才能让面试官信服。

      4、CopyOnWriteArrayList

        CopyOnWrite(简称:COW):即复制再写入,就是在添加元素的时候,先把原 List 列表复制一份,再添加新的元素。

        先来看下它的 add 方法源码:

     1 public boolean add(E e) {
     2     // 加锁
     3     final ReentrantLock lock = this.lock;
     4     lock.lock();
     5     try {
     6         // 获取原始集合
     7         Object[] elements = getArray();
     8         int len = elements.length;
     9 
    10         // 复制一个新集合
    11         Object[] newElements = Arrays.copyOf(elements, len + 1);
    12         newElements[len] = e;
    13 
    14         // 替换原始集合为新集合
    15         setArray(newElements);
    16         return true;
    17     } finally {
    18         // 释放锁
    19         lock.unlock();
    20     }
    21 }

        添加元素时,先加锁,再进行复制替换操作,最后再释放锁。

        再来看下它的 get 方法源码:

    1 private E get(Object[] a, int index) {
    2     return (E) a[index];
    3 }
    4 
    5 public E get(int index) {
    6     return get(getArray(), index);
    7 }

        可以看到,获取元素并没有加锁。

        这样做的好处是,在高并发情况下,读取元素时就不用加锁,写数据时才加锁,大大提升了读取性能。

      5、CopyOnWriteArraySet

        CopyOnWriteArraySet逻辑就更简单了,就是使用 CopyOnWriteArrayList 的 addIfAbsent 方法来去重的,添加元素的时候判断对象是否已经存在,不存在才添加进集合。

     1 /**
     2  * Appends the element, if not present.
     3  *
     4  * @param e element to be added to this list, if absent
     5  * @return {@code true} if the element was added
     6  */
     7 public boolean addIfAbsent(E e) {
     8     Object[] snapshot = getArray();
     9     return indexOf(e, snapshot, 0, snapshot.length) >= 0 ? false :
    10         addIfAbsent(e, snapshot);
    11 }

        这两种并发集合,虽然牛逼,但只适合于读多写少的情况,如果写多读少,使用这个就没意义了,因为每次写操作都要进行集合内存复制,性能开销很大,如果集合较大,很容易造成内存溢出。

      6、使用ThreadLocal

        使用ThreadLocal变量确保线程封闭性(封闭线程往往是比较安全的, 但由于使用ThreadLocal封装变量,相当于把变量丢进执行线程中去,每new一个新的线程,变量也会new一次,一定程度上会造成性能[内存]损耗,但其执行完毕就销毁的机制使得ThreadLocal变成比较优化的并发解决方案)。

    1 ThreadLocal<List<Object>> threadList = new ThreadLocal<List<Object>>() {
    2     @Override
    3     protected List<Object> initialValue() {
    4           return new ArrayList<Object>();
    5     }
    6 };

    五、小结

    下次面试官问你线程安全的 List,你可以从 Vector > SynchronizedList > CopyOnWriteArrayList 这样的顺序依次说上来,这样才有带入感,也能体现你对知识点的掌握程度。

  • 相关阅读:
    YARN源码学习(七)-----Task级别GC相关指标的自定义counter添加
    YARN源码学习(七)-----Task级别GC相关指标的自定义counter添加
    YARN源码分析(八)-----Reduce Shuffle过程分析
    YARN源码分析(八)-----Reduce Shuffle过程分析
    【每天一道算法题】整数循环节之和——数字黑洞6174
    getline函数
    设计模式之建造者模式Builder(创建型)
    字符串算法总结
    C++设计模式之单例模式
    Linux下C的线程同步机制
  • 原文地址:https://www.cnblogs.com/niujifei/p/14705458.html
Copyright © 2020-2023  润新知