一、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 这样的顺序依次说上来,这样才有带入感,也能体现你对知识点的掌握程度。

 

posted on 2021-04-26 16:55  格物致知_Tony  阅读(322)  评论(0编辑  收藏  举报