概述
ArrayList用在多线程环境中存在线程安全问题。关键的原因就是ArrayList底层实现,在新增元素时数组索引的移动操作。
ArrayList的add()方法源码:
Java中 i++ 并非线程安全的,这样多个线程同时往一个ArrayList中加元素,导致元素丢失,出现空洞。那么如果想在多线程环境中使用ArrayList,有哪些保证其线程安全性的方法呢?
代码案例
public class UnsafeArrayList2 {
public static void main(String[] args) {
try {
notThreadSafe();
} catch (Exception e) {
e.printStackTrace();
}
}
public static void notThreadSafe() throws Exception{
final List<Integer> list = new ArrayList<>();
for(int i=0;i<4;i++){
new Thread(()->{
for(int j=0;j<=10000;j++){
list.add(new Random().nextInt(100));
}
}).start();
}
TimeUnit.SECONDS.sleep(3);
System.out.println("size = "+list.size());
for(int i=0;i<list.size();i++){
if(null == list.get(i)){
System.out.println("error==========");
}
}
System.out.println("over===========");
}
}
运行结果:
size = 38879
error==========
error==========
error==========
error==========
error==========
error==========
error==========
error==========
...(此处省略)
增加元素过程中较为容易出现问题的部分在于elementData[size++] = e;.
赋值的过程可以分为两个步骤
elementData[size] = e;
size++;
我们分别使用两个线程来模拟插入过程.例如有两个线程,分别加入数字1与2.
运行的过程如下所示:
1、线程1 赋值 element[1] = 1; 随后因为时间片用完而中断;
2、线程2 赋值 element[1] = 2; 随后因为时间片用完中断;
此处导致了之前所说的一个问题(有的线程没有输出); 因为后续的线程将前面的线程的值覆盖了.
3、线程1 自增 size++; (size=2)
4、线程2 自增 size++; (size=3)
此处导致了某些值为null的问题.因为原size=1, 但是因为线程1与线程2都将值赋值给了element[1],导致了element[2]内没有值,被跳过了.指针index指向了3.所以,导致了某些情况下值为null的情况.
数组越界情况. 我们将上方的线程运行图更新下进行演示:
前提条件: 当前size=2 数组长度为2.
1、线程1 判断数组是否越界.因为size=2 长度为2,没有越界.将进行赋值操作.但是因为时间片问题导致了中断.
2、线程2 判断数组是否越界.因为size=2 长度为2,没有越界.将进行赋值操作.但是因为时间片问题导致了中断.
3、线程1 重新获取到主动权.上文判断了长度刚刚好够用.进行赋值操作element[size]=1,并且size++
4、线程2 因为上文判断了数组没有越界.所以进行赋值操作.但是此时的size=3了.再执行element[3]=2. 导致了数组越界了.
由此处可以看出因为数组的当前指向size并未进行加锁的操作,导致了数组越界的情况出现.
解决方案–同步
既然ArrayList不是线程安全的,第一种很容易想到的方法就是使用synchronized来同步所有的ArrayList操作方法,JDK工具类为我们提供了。Collections.synchronizedList()方法其实底层也是在集合的所有方法之上加上了synchronized(默认使用的是同一个monitor对象,也可以自己指定)。
源码:
代码案例:
public class UnsafeArrayList3 {
public static void main(String[] args) {
try {
notThreadSafe();
} catch (Exception e) {
e.printStackTrace();
}
}
public static void notThreadSafe() throws Exception{
final List<Integer> list = Collections.synchronizedList(new ArrayList<>());
for(int i=0;i<4;i++){
new Thread(()->{
for(int j=0;j<10000;j++){
list.add(new Random().nextInt(100));
}
}).start();
}
TimeUnit.SECONDS.sleep(3);
System.out.println("size = "+list.size());
for(int i=0;i<list.size();i++){
if(null == list.get(i)){
System.out.println("error==========");
}
}
System.out.println("over===========");
}
}
运行结果
size = 40000
over===========
解决方案–COW 写时拷贝
Copy On Write 也是一种重要的思想,在写少读多的场景下,为了保证集合的线程安全性,我们完全可以在当前线程中得到原始数据的一份拷贝,然后进行操作。
JDK集合框架中为我们提供了 ArrayList 的这样一个实现:CopyOnWriteArrayList。但是如果不是写少读多的场景,使用 CopyOnWriteArrayList 开销比较大,因为每次对其更新操作(add/set/remove)都会做一次数组拷贝。
CopyOnWriteArrayList的实现原理
在使用CopyOnWriteArrayList之前,我们先阅读其源码了解下它是如何实现的。以下代码是向CopyOnWriteArrayList中add方法的实现(向CopyOnWriteArrayList里添加元素),可以发现在添加的时候是需要加锁的,否则多线程写的时候会Copy出N个副本出来。
源码:
读的时候不需要加锁,如果读的时候有多个线程正在向CopyOnWriteArrayList添加数据,读还是会读到旧的数据,因为写的时候不会锁住旧的CopyOnWriteArrayList。
CopyOnWrite的缺点
CopyOnWrite容器有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题。所以在开发的时候需要注意一下。
内存占用问题: 因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比如说200M左右,那么再写入100M数据进去,内存就会占用300M,那么这个时候很有可能造成频繁的Yong GC和Full GC。
数据一致性问题:CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。
CopyOnWriteArrayList为什么并发安全且性能比Vector好
我知道Vector是增删改查方法都加了synchronized,保证同步,但是每个方法执行的时候都要去获得锁,性能就会大大下降,而CopyOnWriteArrayList 只是在增删改上加锁,但是读不加锁,在读方面的性能就好于Vector,CopyOnWriteArrayList支持读多写少的并发情况。
总结
在多线程环境下可以使用 Collections.synchronizedList() 或者 CopyOnWriteArrayList 来实现 ArrayList 的线程安全性。虽然 Vector(已废弃) 每个方法也都有同步关键字,但是一般不使用,一方面是慢,另一方面是不能保证多个方法的组合是线程安全的(因为不是基于同一个monitor)。