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

一、ArrayList 线程不安全性

  ArrayList 线程不安全案例:

 1 public class TestArrayList { 2     private static Listlist = 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,然后并不是,这就是传说中的多线程并发问题了。

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

 

二、可能导致的问题

  从以上结果可以总结出 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 Listlist = new ArrayList(); 9         // 线程A将1-1000添加到列表10         new Thread(new Runnable() {11 12             @Override13             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             @Override32             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 }

 

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

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

 

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

 

 

  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 list;2 3 SynchronizedList(List 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> threadList = new ThreadLocal<List>() {2     @Override3     protected List initialValue() {4           return new ArrayList();5     }6 };

 

五、小结

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