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